SpringBoot security 를 사용하려고 했는데, 버전 3.x 가 없어서 제가 직접 찾아서 작성했는데, 다른 분들과 공유하려고 합니다!! 참고로 Spring Security 는 버전마다 코드가 훅훅 바뀌어서 버전이 중요해요!!
SpringBoot 3.2.6
Java 17
spring-boot-starter-security 3.2.6
아래 사이트에서 자신과 맞는 버전 찾기!!
SpringBoot → Learn → 버전별 Reference Doc → 하단 Dependency Version
[ JWT 방식 채용 ]
저희는 기존 계획에 없었는데… 급하게 시연용을 위해서 만들게 돼서…
진짜 로그인 용도로 만들었다기 보다는인증을 한 사용자와 인증을 하지 않은 사용자를 나누기 위함
으로
만들었습니다!! 개인정보가 들어있는 프로젝트라서 저희팀원(= 관리자)가 아닌 사람은 제한된 페이지만 갈 수 있도록 했습니다.
일단 JWT 방식으로 로그인을 구현했고, 실제 로그인을 개인정보 넣고, 회원가입하고 이런 용도가 아니라 우리 팀원만 접속할 수 있도록 만들기 위해서 특정 문자열을 넣은 ID 로 회원가입을 하고, 로그인을 완료하면 토큰을 발급한 후 헤더에 토큰을 넣고 다음 api 요청을 보내야합니다. 이 때 토큰 claim 내에 loginID 를 넣고, API 요청이 오면 Filter 에서 토큰을 검사합니다. loginID 를 해석하고, DB 에 정보가 있다면 인증된 사용자로 요청을 허가합니다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security' // login
implementation 'io.jsonwebtoken:jjwt:0.9.1' // JWT
implementation 'com.sun.xml.bind:jaxb-impl:4.0.1' // com.sun.xml.bind
implementation 'com.sun.xml.bind:jaxb-core:4.0.1' // com.sun.xml.bind
implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' // javax.xml.bind
JwtTokenFilter.java
아래 필터는 모든 reuqest 마다 반응합니다!!
주의점
1. request headers 에 토큰이 있다면 무조건!! 해당 필터를 거쳐갑니다!
2. filterChain.doFilter 는 필터 작업을 종료하고 다음 작업으로 넘긴다는 의미입니다.
3. filter 는 controller, service 단에 도착하기 전에 거쳐가는 곳으로,@Autowire 나 @Value
는 사용할 수 없고, 따로 넣어줘야합니다.아래처럼 생성자를 통해서
// OncePerRequestFilter : 매번 들어갈 때 마다 체크 해주는 필터
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
if(authorizationHeader == null) {
filterChain.doFilter(request, response);
return;
}
// Header의 Authorization 값이 'Bearer '로 시작하지 않으면 => 잘못된 토큰
if(!authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// 전송받은 값에서 'Bearer ' 뒷부분(Jwt Token) 추출
String token = authorizationHeader.split(" ")[1];
// 전송받은 Jwt Token이 만료되었으면 => 다음 필터 진행(인증 X)
if (jwtTokenService.isExpired(token, secretKey)) {
filterChain.doFilter(request, response);
return;
}
// Jwt Token에서 loginId 추출
String loginId = jwtTokenService.getLoginId(token, secretKey);
// 추출한 loginId로 User 찾아오기
UserEntity loginUser = userService.getLoginUserByLoginId(loginId);
// loginUser 정보로 UsernamePasswordAuthenticationToken 발급
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUser.getLoginId(), null, List.of(new SimpleGrantedAuthority(loginUser.getRole().name())));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 권한 부여
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
BCryptConfig.java
주의점
1. 비밀번호 암호화를 위해서
@Configuration
public class BCryptConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
SecurityConfig.java
주의점
1. 아래 문법이 이전 버전과 많이 달라졌습니다! 해당 글은 springboot 3.2.6 버전을 기준으로 합니다
2.requestMatchers 를 이용해 내가 원하는 api path 와 해당 경로의 특정 method 를 지정하고, 원하는 권한에 대하여 인가
를 할 수 있습니다!
3.permitAll() 은 모두 허용, authenticated() 는 인증된 사람, hasAuthority() 는 특정 권한에 대해서!
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenService jwtTokenService;
private final UserService userService;
@Value("${jwt.secretKey}")
private String secretKey;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(http -> http.disable())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtTokenFilter(jwtTokenService, userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/guardian/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/missing-people").permitAll()
.requestMatchers("/api/user/info/**").authenticated()
.requestMatchers("/api/user/**").permitAll()
.anyRequest().hasAuthority(UserRole.ADMIN.name())
)
.build();
}
}
JwtLoginApiController.java
주의점
1.DTO
는 상황마다 다르고, 너무 개별적인 부분이라여러분이 직접 작성하셔야 돼요!
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class JwtLoginApiController {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.expireTime}")
private long expireTime;
@Autowired
private UserService userService;
@Autowired
private JwtTokenService jwtTokenService;
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody JoinRequestDto joinRequestDto) {
// loginId 중복 체크
userService.checkLoginIdDuplicate(joinRequestDto.getLoginId());
// 회원가입
userService.join(joinRequestDto);
return ResponseEntity.ok().body(new SuccessResponse("Join Success"));
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequestDto loginRequestDto) {
// 로그인
UserEntity user = userService.login(loginRequestDto);
// Jwt Token 발급
String jwtToken = jwtTokenService.createToken(user.getLoginId(), secretKey, expireTime);
LoginResponseDto loginResponseDto = new LoginResponseDto(user, jwtToken);
return ResponseEntity.ok().body(new SuccessResponse(loginResponseDto));
}
@GetMapping("/info")
public ResponseEntity<?> userInfo(Authentication auth) {
UserEntity loginUser = userService.getLoginUserByLoginId(auth.getName());
return ResponseEntity.ok().body(new SuccessResponse(String.format("loginId : %s, role : %s",
loginUser.getLoginId(), loginUser.getRole().name())));
}
}
JwtTokenService
주의점
1. 보통 jwtUtil 같은 이름으로 사용을 합니다! 우리는 아니지만…?
2.claim 내에 원하는 정보
를 수정해서 사용해주세요!
@Service
public class JwtTokenService {
// JWT Token 발급
public static String createToken(String loginId, String secretKey, long expireTime) {
try {
// Claim = Jwt Token에 들어갈 정보
// Claim에 loginId를 넣어 줌으로써 나중에 loginId를 꺼낼 수 있음
Claims claims = Jwts.claims();
claims.put("loginId", loginId);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expireTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
} catch (Exception e) {
throw new CustomException(ErrorCode.JWT_CREATE_ERROR);
}
}
// Claims에서 loginId 꺼내기
public static String getLoginId(String token, String secretKey) {
return extractClaims(token, secretKey).get("loginId").toString();
}
// 발급된 Token이 만료 시간이 지났는지 체크
public static boolean isExpired(String token, String secretKey) {
Date expiredDate = extractClaims(token, secretKey).getExpiration();
// Token의 만료 날짜가 지금보다 이전인지 check
return expiredDate.before(new Date());
}
// SecretKey를 사용해 Token Parsing
private static Claims extractClaims(String token, String secretKey) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
}
}
UserRepository.java
주의점
1.Optional return
을 하기 때문에, 알아서 수정해서 쓰셔도 됩니다!
public interface UserRepository extends JpaRepository<UserEntity, Long>{
boolean existsByLoginId(String loginId);
Optional<UserEntity> findByLoginId(String loginId);
}
ErrorCode.java ← 이건 저희팀 에러 처리 방식입니다. 굳이 안쓰셔도 됩니다
// Login Error
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found by login id."),
USER_NOT_MATCH_PASSWORD(HttpStatus.UNAUTHORIZED, "Invalid password"),
JWT_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Can not create JWT token"),
USER_NOT_ADMIN(HttpStatus.UNAUTHORIZED, "User not Admin, Please contact probee team"),
DUPLICATE_USER_LOGIN_ID(HttpStatus.CONFLICT, "Duplicate user login id.");