전반적인 흐름
1. 로그인 요청 (Sign-in)
- 사용자가 /auth/sign-in 엔드포인트로 아이디와 비밀번호를 포함한 로그인 요청을 보낸다.
- AuthController의 login 메서드에서 LoginRequestDto를 이용해 사용자 입력을 받아 AuthService.signIn()을 호출하여 검증한다.
- 검증이 성공하면 사용자의 ID (memberId) 를 반환한다.
2. JWT 토큰 생성
- AuthController에서 로그인 성공 시 JWT(Json Web Token) 을 생성한다.
- JWT 생성 과정:
- 비밀키: AppConfig.getJwtKey()를 사용하여 서명에 사용할 SecretKey를 불러온다.
- 만료시간 설정: 현재 시간(now)을 기준으로 1시간(Duration.ofHours(1)) 후 만료되도록 설정한다.
- 토큰 생성: Jwts.builder()를 이용하여 subject(사용자 ID), 발급 시간, 만료 시간을 포함한 JWT를 생성한다.
- 서명: SecretKey를 사용해 HS256 알고리즘으로 서명한다.
- JWT 문자열 반환: .compact()를 호출하여 최종적으로 JWT 문자열을 생성한다.
3. JWT 저장 (HttpOnly 쿠키)
- 생성된 JWT는 보안성을 위해 HttpOnly 쿠키에 저장된다.
- ResponseCookie를 사용해 HttpOnly, Secure, path="/", maxAge=1시간 설정을 추가하고 response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())를 통해 응답 헤더에 추가한다.
- 클라이언트는 이후 요청 시 해당 쿠키를 자동으로 전송한다.
4. JWT 인증 및 검증
- 사용자가 보호된 API에 접근하면 AuthResolver가 실행된다.
- AuthResolver의 resolveArgument()에서 JWT를 검증하는 과정:
- JWT 추출: HTTP 요청 헤더에서 "Authorization" 값을 가져온다.
- 검증 수행:
- Jwts.parserBuilder()를 이용해 appConfig.getJwtKey()로 서명 검증을 수행하고, JWT가 유효한지 확인한다.
- 토큰이 유효하면 claims.getBody().getSubject()에서 사용자 ID를 추출하여 MemberSession 객체를 생성한다.
- 실패하면 Unauthorized 예외를 발생시킨다.
5. 인증된 요청 처리
- WebMvcConfig에서 addArgumentResolvers()를 통해 AuthResolver를 등록하여, 컨트롤러의 MemberSession 파라미터가 자동으로 주입되도록 설정한다.
- 인증이 필요한 API 요청 시 AuthResolver에서 JWT를 검증하여 사용자 정보를 컨트롤러에 전달한다.
- 검증된 MemberSession을 사용해 현재 로그인한 사용자의 정보를 활용할 수 있다.
6. JWT 기반 접근 제어
- 특정 API에 접근할 때 MemberSession을 파라미터로 요구하여 JWT가 검증된 사용자만 접근하도록 제한할 수 있다.
- 이를 통해 세션 기반 인증을 대체하여 JWT 기반 인증 시스템을 구축할 수 있다.
🔑 정리
- 사용자가 로그인 요청을 보낸다.
- 서버가 사용자 정보를 검증하고 JWT를 생성한다.
- JWT를 HttpOnly 쿠키로 저장하여 보안성을 높인다.
- 사용자가 API 요청 시 Authorization 헤더에서 JWT를 전달한다.
- AuthResolver가 JWT를 검증하여 사용자를 인증한다.
- 인증된 사용자는 API에 접근할 수 있다.
소스코드
@Controller
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final AppConfig appConfig;
@GetMapping("/sign-in")
public String showLogin(Model model) {
model.addAttribute("loginDto", LoginRequestDto.builder().build());
return "loginForm";
}
@PostMapping("/sign-in")
@ResponseBody
public ResponseEntity<?> login(@RequestBody LoginRequestDto login,
HttpServletResponse response) {
try {
Long memberId = authService.signIn(login);
// 토큰 생성할때 사용
// Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// byte[] encodedKey = key.getEncoded();
// String strKey = Base64.getEncoder().encodeToString(encodedKey); // String으로 변환
SecretKey key = Keys.hmacShaKeyFor(appConfig.getJwtKey());
Date now = new Date();
Date expiryDate = new Date(now.getTime() + Duration.ofHours(1).toMillis()); // 1시간 후 만료
String jws = Jwts.builder()
.setSubject(String.valueOf(memberId))
.signWith(key)
.setIssuedAt(now) // 발급 시간
.setExpiration(expiryDate) // 만료일
.compact();
// HttpOnly 쿠키에 저장.
ResponseCookie cookie = ResponseCookie.from("jwt", jws)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(Duration.ofHours(1))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(new SessionResponse(jws));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body("아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
@Data
@ConfigurationProperties(prefix = "dj")
public class AppConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
private byte[] jwtKey;
public void setJwtKey(String jwtKey) {
this.jwtKey = Base64.getDecoder().decode(jwtKey);
}
public byte[] getJwtKey() {
return jwtKey;
}
}
@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {
private final SessionRepository sessionRepository;
private final AppConfig appConfig;
@Override
public boolean supportsParameter(MethodParameter parameter) { //파라미터에 MemberSession이 있는지 확인
return parameter.getParameterType().equals(MemberSession.class);
}
// 있으면 아래 메소드 실행
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
log.info(">>>{}", appConfig.toString());
String jws = webRequest.getHeader("Authorization");
if (jws == null || jws.equals("")) {
throw new Unauthorized();
}
try {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(appConfig.getJwtKey())
.build()
.parseClaimsJws(jws);
String memberId = claims.getBody().getSubject();
return new MemberSession(Long.parseLong(memberId));
} catch (JwtException e) {
throw new Unauthorized();
}
}
}
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final SessionRepository sessionRepository;
private final AppConfig appConfig;
// 인터셉터 등록(주석)
// @Override
// public void addInterceptors(InterceptorRegistry registry) {
// registry.addInterceptor(new AuthInterceptor())
// .excludePathPatterns("/error", "/favicon.ico");// 인증없이도 접근 가능한 URL
// }
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthResolver(sessionRepository, appConfig));
}
}