728x90
SpringBoot와 Next.js를 JWT 토큰을 이용하여 로그인 인증하는 방법에 대해 간단하게 알아보겠습니다.
개발환경은 아래와 같습니다.
1. 개발환경
- SpringBoot
- 버전 : 3.3.2
- IDE : IntelliJ
- Next.js
- 버전 : 14.2.4
- IDE : vscode
2. SpringBoot 소스작성
- DB는 H2 Database Embedded 모드로 테스트하였습니다.
- JPA를 이용하여 H2 DB와 맵핑하였습니다.
2.1. build.gradle 설정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
runtimeOnly 'com.h2database:h2'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
2.2. application.yml
server:
port: 1122
spring:
datasource:
#url: jdbc:h2:mem:testdb # 메모리 모드
url: jdbc:h2:file:D:/H2DB/myBoard;AUTO_SERVER=TRUE # 파일 모드
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: update # create(메모리), update(파일)
show-sql: true
database-platform: org.hibernate.dialect.H2Dialect
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
show_sql: true
- H2 DB 는 embedded 모드를 사용하였습니다.
2.3. JPA 맵핑 테이블 작성
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 30)
private String username;
@Column(nullable = false)
private String password;
}
- User는 H2 DB의 예약어이기 때문에 테이블명을 members로 지정합니다.
2.4. 레파지토리 작성
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
2.5. Request / Response DTO 작성
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class JwtReq {
private String username;
private String password;
}
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class JwtRes {
private final String jwt;
}
- 아이디/패스워드를 받아 JWT 토큰 값을 응답해줍니다.
2.6. JwtUtil.java 작성
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtUtil {
private final String secret = "my-secret-key";
public String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 10)) // 10분
.signWith(SignatureAlgorithm.HS256, secret.getBytes())
.compact();
}
public Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(secret.getBytes()).parseClaimsJws(token).getBody();
}
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
public Boolean isTokenExpired(String token) {
return extractAllClaims(token).getExpiration().before(new Date());
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
- 키는 별도의 설정파일로 관리하는 것을 추천드립니다.
2.7. 필터작성
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
@Autowired
public JwtRequestFilter(UserDetailsService userDetailsService, JwtUtil jwtUtil) {
this.userDetailsService = userDetailsService;
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
username = jwtUtil.extractUsername(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}
*UserDetailsService Bean 오류가 발생한다면, 의도적인 주입이 필요합니다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles("USER") // 필요에 따라 역할 추가
.build();
}
}
2.8. SecurityConfig 작성
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtRequestFilter jwtRequestFilter;
public SecurityConfig(JwtRequestFilter jwtRequestFilter) {
this.jwtRequestFilter = jwtRequestFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/auth/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
.cors(cors -> cors.configurationSource(request ->
new CorsConfiguration().applyPermitDefaultValues()
));;
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- /auth와 /h2-console로 접근하는 URL 은 제외시킵니다.
2.9. WebConfig 작성
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000") // Frontend origin
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}
2.10. AuthController 작성
import com.board.api.jwt.config.JwtUtil;
import com.board.api.jwt.dto.JwtReq;
import com.board.api.jwt.dto.JwtRes;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class JwtAuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<?> createAuthenticationToken(@RequestBody JwtReq req) throws Exception {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
);
} catch (BadCredentialsException e) {
throw new Exception("ID 또는 PW 틀림", e);
}
final UserDetails userDetails = new User(req.getUsername(), req.getPassword(), new ArrayList<>());
final String jwt = jwtUtil.generateToken(userDetails.getUsername());
return ResponseEntity.ok(new JwtRes(jwt));
}
}
2.11. UserController 작성
import com.board.api.jwt.jpa.User;
import com.board.api.jwt.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/profile")
public User getProfile() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
User user = userService.getUserByUsername(username);
return user;
}
}
2.12. UserService 작성
import com.board.api.jwt.jpa.User;
import com.board.api.jwt.jpa.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public User getUserByUsername(final String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("유저없음"));
}
}
3. Next.js 소스 작성
- axios를 이용하여 API 를 호출합니다.
- npm install axios 를 입력하여 패키지를 설치합니다.
3.1. 로그인 페이지 작성
import { useState } from "react";
import axios from 'axios'
import { useRouter } from "next/router";
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const handleSubmit = async (event) => {
event.preventDefault();
try {
const response = await axios.post('http://localhost:1122/auth/login', { username, password });
localStorage.setItem('token', response.data.jwt);
router.push('/profile');
} catch (error) {
console.error('로그인 실패', error);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Username:</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div>
<label>Password:</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<button type="submit">로그인</button>
</form>
);
}
- axios 를 백엔드 API를 호출합니다.
- 로그인에 성공하였다면, 로컬스토리지에 토큰을 저장하고 /profile 페이지로 이동합니다.
3.2. 프로필 페이지 작성
import { useEffect, useState } from "react";
import axios from "axios";
export default function Profile() {
const [profile, setProfile] = useState(null);
useEffect(() => {
const fetchProfile = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('http://localhost:1122/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
// 상태 업데이트: 응답에서 데이터 추출 및 설정
setProfile(response.data);
} catch (error) {
console.error('프로필 패치 오류', error);
}
};
fetchProfile();
}, []);
if (!profile) return <div>Loading...</div>;
return (
<div>
<h1>Profile</h1>
<p>Username: {profile.username}</p>
</div>
);
}
- 로컬스토리지에서 토큰을 조회하고 Header Authorization 속성에 포함시켜 요청합니다.
3.3. H2 DB 데이터 입력
INSERT INTO MEMBERS(USERNAME, PASSWORD) VALUES(
'user1',
'$2a$12$rU4r8aHV6Kiq14aHTMsvBeg7tR4hCzdBqu4e33COQ935JcLos3lDa'
)
- 패스워드는 BCrypto로 암호화하여 저장해야 합니다.
4. 실행화면
감사합니다.
728x90
'프레임워크 > SpringBoot' 카테고리의 다른 글
[SpringBoot] Thymeleaf를 이용한 카카오톡 클론 코딩 - 2 (0) | 2024.08.07 |
---|---|
[SpringBoot] Thymeleaf를 이용한 카카오톡 클론 코딩 - 1 (0) | 2024.08.07 |
[SpringBoot] h2 consloe localhost에서 연결을 거부했습니다. (0) | 2024.07.31 |
[SpringBoot] GraphQL 간단한 예제 (0) | 2024.06.19 |
[SpringBoot] jpg파일을 webp 파일로 변경하기(twelvemonkeys) (0) | 2024.06.18 |