/auth/register{
"email": "user@example.com",
"password": "123456"
}
Response:
200 OK400 Bad Request/auth/login{
"email": "user@example.com",
"password": "123456"
}
Response:
200 OK{
"token": "JWT_ACCESS_TOKEN"
}401 Unauthorized{
"error": "Invalid credentials"
}POST /auth/logout
Headers:
Authorization: Bearer JWT_ACCESS_TOKEN
Response:
200 OK Logged out successfully먼저 들어가기 전 예외 처리를 위한 클래스 추가
import lombok.Getter;
@Getter
public enum JwtExceptionCode {
UNKNOWN_ERROR("UNKNOWN_ERROR", "알 수 없는 오류"),
NOT_FOUND_TOKEN("NOT_FOUND_TOKEN", "Headers에 토큰 형식의 값 찾을 수 없음"),
INVALID_TOKEN("INVALID_TOKEN", "유효하지 않은 토큰"),
EXPIRED_TOKEN("EXPIRED_TOKEN", "기간이 만료된 토큰"),
UNSUPPORTED_TOKEN("UNSUPPORTED_TOKEN", "지원하지 않는 토큰");
private final String code; //값이 변하지 않도록 final
private final String message;
JwtExceptionCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String toString() { //예외 메시지를 출력할 때 더 유용한 정보 제공.
return String.format("[%s] %s", code, message);
}
}
천천히 추가할 예정이다
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/oauth/*","/").permitAll()
.anyRequest().authenticated())
.csrf(csrf-> csrf.disable());
return http.build();
}
authorizeHttpRequests는 Spring Security의 설정 메서드 중 하나로, HTTP 요청에 대한 접근 제어를 설정하는 데 사용됩니다. 이 메서드는 특정 URL 패턴에 대해 사용자의 인증 및 권한을 요구하거나 제한할 수 있도록 도와준다authorizeHttpRequests는 보통 http.security() 설정의 일부로 사용되며, 주로 어떤 URL이 특정 역할이나 권한을 가진 사용자에게만 허용될지 정의한다. public CorsConfigurationSource configurationSource(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**",config);
return source;
}
//모두 허용한다고 해놨지만, 특정 포트만 접근할 수 있게 정할 수 있음
config.addAllowedOrigin("*");
//헤더 정보-> ContetType/json 뭐 이런거 Authrization 등 원하는 헤더만 들어올 수 있게 할 수 있다
//*는 다 들어올 수 있다는 뜻 ㅇㅇ
config.addAllowedHeader("*");
//모든 메서드 허락
config.addAllowedMethod("*");
//특정 메서드 허락
//get, post, delete 메서드만 허용한다 뭐 이런거 설정할 수 있음
//아래 코드가 있을때 Put은 동작하지 않는다.
config.setAllowedMethods(List.of("GET","POST","DELETE"));
//url 설정을 넣어주는 역할, /admin으로 들어온 url은 특정 config를 넣어줄 수 있따
//여러개의 config를 만들고, 원하는 url에 맞춰서 사용할 수 있음
// /admin-> put만 가능하게 , /user -> post만 가능하게
source.registerCorsConfiguration("/**",config);
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@PostMapping("/login")
public ResponseEntity<UserLoginResponseDto> login(@RequestBody UserRequestDto userRequestDto){
User user = userService.findByUserEmail(userRequestDto.getEmail());
if(!userService.validPassword(userRequestDto.getPassword(),user.getPassword())){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String accessToken = jwtTokenizer.createAccessToken(user.getId(),user.getEmail());
String refreshToken = jwtTokenizer.refreshAccessToken(user.getId(),user.getEmail());
UserLoginResponseDto loginResponseDto=UserLoginResponseDto.builder()
.accessToken(accessToken)
.userId(user.getId())
.email(user.getEmail())
.build();
return ResponseEntity.ok(loginResponseDto);
}
유효한 비밀번호인지 확인을 해준다.
public boolean validPassword(String dtoPassword, String userPassword){ return passwordEncoder.matches(dtoPassword,userPassword); }
- 만약 유효하지 않은 비밀번호라면 return
- accessToken, refreshToken을 만들어둔 jwtTokenizer에서 발급받는다
- 응답에 값을 채워준 후 return
jwtTokenizer는 토큰 생성과 검증을 해주는 클래스이다. 로그인을 구현한기전에 먼저 생성해야 한다 ! (아래 링크 참조)
AbstractAuthenticationToken을 상속받아 인증 관련 정보를 담는 객체를 생성한다.
- Spring Security에서 로그인 인증을 처리할 때 Authentication 객체를 사용하는데
- JWT를 기반으로 인증을 할 것이기 때문에 별도의
JwtAuthenticationToken클래스를 만든 것이다.
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private String token;
private Object principal;
private Object credentials;
public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities,
Object principal, Object credentials) {
super(authorities);
this.principal =principal;
this.credentials = credentials;
this.setAuthenticated(true);
}
public JwtAuthenticationToken(String token){
super(null);
this.token = token;
this.setAuthenticated(false);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
}
token: JWT 문자열을 저장할 변수principal: 사용자 정보 (보통 UserDetails 객체)credentials: 사용자 인증 정보 (보통은 비밀번호인데, JWT 기반 인증에서는 사용 X)Collection<? extends GrantedAuthority> authoritiessuper(authorities)AbstractAuthenticationToken에게 권한 정보 넘김this.setAuthenticated(true)JwtAuthenticationFilter에서 JWT를 파싱하여 사용자 정보를 추출한다.JwtAuthenticationToken 객체를 생성하여 Spring Security의 SecurityContext에 저장public class CustomerUserDetails implements UserDetails {
Long id;
String email;
String password;
public CustomerUserDetails(Long id, String email, String password) {
this.id = id;
this.email = email;
this.password = password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 현재는 권한을 사용하지 않으므로 빈 리스트 반환
return Collections.emptyList();
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
}
getUsername()에 고유한 식별자를 넣는 것이 중요하며, 이메일이 고유한 값이라면 이메일을 넣는 것이 합리적이다.public class JwtAuthenticationFilterProject extends OncePerRequestFilter{
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
}
}
OncePerRequestFilter는 Spring Security에서 제공하는 추상 클래스이다.OncePerRequestFilter는 요청에 대해 단 한번만 필터링을 하도록 보장doFilterInternal 메서드는 HTTP 요청을 처리하는 곳이다.request에서 token을 빼오기 위한 메서드
public String getToken(HttpServletRequest request){
//헤더에 있는지 확인
String authorization = request.getHeader("Authorization");
if(StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")){
return authorization.substring(7);
}
//쿠키에 있는지 확인
Cookie[] cookies = request.getCookies();
if(cookies!=null){
for(Cookie cookie : cookies){
if("accessToken".equals(cookie.getName())){
return cookie.getValue();
}
}
}
//헤더에도 없고, 쿠키에도 없다면 ?
return null;
}
Authentication 객체를 생성하고 이를 SecurityContextHolder에 저장하는 메서드
private Authentication getAuthentication(String token){
Claims claims = jwtTokenizer.parseAccessToken(token);
String email = claims.getSubject();
Long userId = claims.get("userId",Long.class);
CustomerUserDetails customerUserDetails
= new CustomerUserDetails(userId,email,"");
//권한이 없기 때문에 빈 리스트 값을 넘겨준다.
return new JwtAuthenticationToken(Collections.emptyList(),customerUserDetails,null);
}
GrantedAuthority 인터페이스를 구현한 객체로 관리한다. @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = getToken(request);
if(StringUtils.hasText(token)){
try{
Authentication authentication = getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication)
}catch (ExpiredJwtException e){
request.setAttribute("exception", JwtExceptionCode.EXPIRED_TOKEN.getCode());
log.error("Expired Token : {}",token,e);
SecurityContextHolder.clearContext();
throw new BadCredentialsException("Expired token exception", e);
}catch (UnsupportedJwtException e){
request.setAttribute("exception", JwtExceptionCode.UNSUPPORTED_TOKEN.getCode());
log.error("Unsupported Token: {}", token, e);
SecurityContextHolder.clearContext();
throw new BadCredentialsException("Unsupported token exception", e);
} catch (MalformedJwtException e) {
request.setAttribute("exception", JwtExceptionCode.INVALID_TOKEN.getCode());
log.error("Invalid Token: {}", token, e);
SecurityContextHolder.clearContext();
throw new BadCredentialsException("Invalid token exception", e);
} catch (IllegalArgumentException e) {
request.setAttribute("exception", JwtExceptionCode.NOT_FOUND_TOKEN.getCode());
log.error("Token not found: {}", token, e);
SecurityContextHolder.clearContext();
throw new BadCredentialsException("Token not found exception", e);
}
}
filterChain.doFilter(request,response);
}
1️⃣ getToken을 통해서 토큰을 얻어 온다.
2️⃣ getAuthentication을 통해서 Authentication을 생성한다.
userId, email 등).UserDetails(ex: CustomerUserDetails)를 생성하고,JwtAuthenticationToken 객체를 생성.3️⃣ 생성한 Authentication을 security가 사용할 수 있도록 SecurityContextHolder에 추가한다.
Spring Security는 SecurityContextHolder에 저장된 Authentication을 통해 현재 사용자 정보를 확인한다.
doFilter()는 필터 체인에서 다음 필터로 요청과 응답을 전달하는 역할 @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/oauth/*","/","/oauth").permitAll()
.anyRequest().authenticated())
//시큐리티가 시작하기 전에 사용될 수 있도록추가한다.
.addFilterBefore(new JwtAuthenticationFilterProject(jwtTokenizer), UsernamePasswordAuthenticationFilter.class)
.csrf(csrf-> csrf.disable())
.formLogin(form -> form.disable())
.cors(cors -> cors.configurationSource(configurationSource()));
return http.build();
}
UsernamePasswordAuthenticationFilter는 기본적으로 폼 로그인(아이디+비밀번호) 인증을 담당한다.
JwtAuthenticationFilterProject가 Spring Security 인증 프로세스에서 가장 먼저 실행됨.
시큐리티에서 인증되지 않은 사용자가, 인증해야 사용할 수 있는 보호된 리소스에 접근하려고 할때 동작하는 인터페이스
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
String exception = (String) request.getAttribute("exception");
if(isRestRequest(request)){
//rest로 요청이 들어왔을때 수행 코드
handleRestResponse(request,response,exception);
}else{
//page로 요청이 들어왔을때 수행 코드
//handlePageResponse(request,response,exception);
}
}
지금 요청이 rest인지, page인지 확인하는 메서드
private boolean isRestRequest(HttpServletRequest request) {
String requestedWithHeader = request.getHeader("X-Requested-With");
//page로 요청이 들어오면 false 반환됨 ㅇㅇ
//false면 PAGE 요청 브라우저 직접 요청 HTML @Controller
return "XMLHttpRequest".equals(requestedWithHeader) //true면 ajax
|| request.getRequestURI().startsWith("/api/"); //true면 REST API
}
Restful로 요청이 들어왔을때 동작하는 메서드
private void handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
log.error("Rest Request - Commence Get Exception : {}", exception);
if (exception != null) {
if (exception.equals(JwtExceptionCode.INVALID_TOKEN.getCode())) {
log.error("entry point >> invalid token");
setResponse(response, JwtExceptionCode.INVALID_TOKEN);
} else if (exception.equals(JwtExceptionCode.EXPIRED_TOKEN.getCode())) {
log.error("entry point >> expired token");
setResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
} else if (exception.equals(JwtExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
log.error("entry point >> unsupported token");
setResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
} else if (exception.equals(JwtExceptionCode.NOT_FOUND_TOKEN.getCode())) {
log.error("entry point >> not found token");
setResponse(response, JwtExceptionCode.NOT_FOUND_TOKEN);
} else {
setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
}
} else {
setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
}
}
page로 요청이 들어왔을때 동작하는 메서드
//페이지 요청 중에 예외가 발생했다면, 로그 남기고, 무조건 /loginform으로 리다이렉트
private void handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
log.error("Page Request - Commence Get Exception : {}", exception);
if (exception != null) {
// 추가적인 페이지 요청에 대한 예외 처리 로직을 여기에 추가할 수 있다.
}
response.sendRedirect("/login-form");
}
예외 발생 시 응답 형식을 설정한다.
private void setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401
HashMap<String, Object> errorInfo = new HashMap<>();
errorInfo.put("message", exceptionCode.getMessage());
errorInfo.put("code", exceptionCode.getCode());
Gson gson = new Gson();
String responseJson = gson.toJson(errorInfo);
response.getWriter().print(responseJson);
}
private final JwtTokenizer jwtTokenizer;
private final CustomerAuthenticationEntryPoint customerAuthenticationEntryPoint;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/oauth/*","/","/oauth").permitAll()
.anyRequest().authenticated())
.addFilterBefore(new JwtAuthenticationFilterProject(jwtTokenizer), UsernamePasswordAuthenticationFilter.class)
.csrf(csrf-> csrf.disable())
.formLogin(form -> form.disable())
.cors(cors -> cors.configurationSource(configurationSource()))
.exceptionHandling(excpetion -> excpetion
.authenticationEntryPoint(customerAuthenticationEntryPoint));;
return http.build();
}
@PostMapping("/login")
public ResponseEntity<UserLoginResponseDto> login(HttpServletResponse response,@RequestBody UserRequestDto userRequestDto){
User user = userService.findByUserEmail(userRequestDto.getEmail());
if(!userService.validPassword(userRequestDto.getPassword(),user.getPassword())){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String accessToken = jwtTokenizer.createAccessToken(user.getId(),user.getEmail());
//String refreshToken = jwtTokenizer.refreshAccessToken(user.getId(),user.getEmail());
//만약 토큰을 쿠키로 저장하고 싶다면 ?
Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000));
response.addCookie(accessTokenCookie);
UserLoginResponseDto loginResponseDto=UserLoginResponseDto.builder()
.accessToken(accessToken)
.userId(user.getId())
.email(user.getEmail())
.build();
return ResponseEntity.ok(loginResponseDto);
}
//기본적으로 사용자가 UserDetailsService 를 제공하지 않으면 시큐리티는 자동으로 생성.. 그게 비밀번호 생성함.
//우리는 CustomUserDetailsService 를 만들었음.
// 첫번째 해결방법
@Bean
public UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(); // 빈 등록만 하고 사용자 추가 X
}
//두번째 해결방법
@Bean
public UserDetailsService userDetailsService() {
return username -> null; // 빈만 등록하고 로직은 사용하지 않음
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(authProvider);
}
//세번째..
spring.security.user.name=
spring.security.user.password=