하 이번 작업은 정말 너무 힘들었다. 기간도 오래 걸렸고 아직도 공부해야 할 것들이 산더미처럼 쌓여있다. 그래도! 기능은 잘 작동하니... 머릿속에서 휘발되기 전에 포스팅을 작성하며 기록해두고 하나씩 리팩토링 하면서 나의 지식으로 만들어야겠다.... 로그인 기능 구현과 JWT 생성 관련해서는 이전 포스팅에서 다루었으니 이번에는 Access Token 생성과 로그아웃 기능에 대해 작성해보겠다. SpringBoot와 Security, JWT 작업을 하고자 하는 분은 하나씩 천천히 읽으면서 따라오시길 바란다.
(잘못 이해하고 있거나 수정이 필요한 부분이 있다면 가감 없는 조언과 함께 의견을 댓글로 달아주십시오. 겸허하게 받아들이고 더욱 공부하겠습니다. 미리 감사의 인사를 드립니다...)
Spring Security
이전 포스팅에서 Spring Security 초기 설정과 JWT 생성에 대해 작성했었다. 의존성 주입을 하고 접근 가능한 요청을 설정했었다. 이번 포스팅에서는 로그인을 해야지만 페이지 이동이 가능하게(Spring Security가 요청을 처리하도록) 처리를 하고, 인증할 때 Access Token을 활용하는 과정에 대해 작성해 보겠다.
1. SecurityConfig 수정
로그인하지 않은 유저가 글을 작성할 수 없고, 글에 대해 좋아요를 누를 수 없으며 댓글 작성도 불가능하도록 프로젝트 콘셉트를 선정하였다. 다만 게시글을 읽는 권한 정도는 허용하고자 하는데, 그 부분은 모든 작업이 완료되고 나서 적용하려 한다. 일단 메인 페이지와 로그인 및 회원가입 페이지를 제외한 모든 페이지는 로그인을 하게끔 설정하였다. 코드를 보자.
package kr.co.vibevillage.security.config;
import kr.co.vibevillage.jwt.filter.JwtAuthorizationFilter;
import kr.co.vibevillage.jwt.filter.LoginPageFilter;
import kr.co.vibevillage.jwt.provider.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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;
@Configuration
@EnableWebSecurity // 스프링시큐리티 필터가 스프링 필터체인에 등록된다
public class SecurityConfig {
// application.properties에 작성해둔 암호화된 secretKey
@Value("${jwt.secret}")
private String secretKey;
// JwtTokenProvider: JWT 토큰 생성, 토큰 복호화 및 추출, 토큰 유효성 검증 기능을 하는 클래스
@Bean
public JwtTokenProvider jwtTokenProvider() {
return new JwtTokenProvider(secretKey);
}
// 비밀번호 암호화
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// HttpSecurity 객체를 사용해 웹 보안 설정을 구성
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/css/**", "/js/**", "/images/**", "/lib/**", "/scss/**").permitAll() // 정적 리소스에 대한 접근을 허용
.requestMatchers("/form/login").permitAll() // 로그인 페이지 접근 허용
.requestMatchers("/login").permitAll() // 로그인 처리 URL 접근 허용
.requestMatchers("/form").permitAll() // 메인 페이지 접근 허용
.requestMatchers("/sendCertificationNumber").permitAll() // coolSMS
.requestMatchers("/certification").permitAll() // coolSMS
.requestMatchers("/checkId").permitAll() // 닉네임 중복검사
.requestMatchers("/checkNickName").permitAll() // 닉네임 중복검사
.requestMatchers("/register").permitAll() // 회원가입
.requestMatchers("/profile").permitAll() // 회원정보 가져오기
.requestMatchers(
"/experienceBoard/**", // 경험 및 리뷰 게시판
"/used/**", // 중고거래 게시판
"/chat/**", // 채팅
"/customerService/**", // 고객센터 게시판
"/levelUp/**" // 등업 게시판
).authenticated() // 지정된 URL 패턴에 대한 접근은 인증이 필요함
.anyRequest().authenticated() // 모든 다른 요청은 인증이 필요함
)
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
.formLogin(form -> form
.loginPage("/form/login") // 커스텀 로그인 페이지 설정
.successHandler((request, response, authentication) -> {
response.sendRedirect("/form"); // 로그인 성공 시 리다이렉트
})
.permitAll()) // 로그인 관련 요청 허용
.logout(logout -> logout
.logoutUrl("/logout") // 로그아웃 처리 URL 설정
.logoutSuccessUrl("/form/login") // 로그아웃 후 리다이렉트할 URL 설정
.deleteCookies("JWT", "AccessToken", "JSESSIONID") // 로그아웃 시 삭제할 쿠키들 설정
.invalidateHttpSession(true) // 로그아웃 시 세션 무효화
.clearAuthentication(true) // 로그아웃 시 인증 정보 제거
.permitAll()) // 로그아웃 관련 모든 요청 허용
.addFilterBefore(new JwtAuthorizationFilter(jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new LoginPageFilter(jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
1-1. http.authorizeHttpRequests
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/css/**", "/js/**", "/images/**", "/lib/**", "/scss/**").permitAll() // 정적 리소스에 대한 접근을 허용
.
.
.
(생략)
로그인을 하지 않아도 접속할 수 있도록 설정하는 부분이다. 회원이 로그인을 하게 되면 JWT(Jason Web Token)가 생성되고, 생성된 토큰을 이용해 인증을 하는 방식인데, 위와 같이 명시해 주면 URL 요청대한 처리에 대한 인증이 필요 없어(?) 진다. 개인 컴퓨터에서 작업하여 GitHub를 이용해 형상관리를 하고 있기에 다른 팀원들의 요청 URL에 대해 너그러운(?) 설정을 해두었다. 작업할 때마다 인증이 필요하다고 하면 정말 스트레스받기에..
1-2. csrf
원활한 접근을 위해... 사이트 위변조 방지(csrf)도 해제해 주었다. (csrf 관련 내용은 이 포스팅을 참고하길 바란다.)
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
1-3.. formLogin
로그인 관련 설정하는 부분이다. 로그인을 요청하는 URL을 설정하고 성공했을 때 메인 화면으로 넘어가게 설정하였다.
.formLogin(form -> form
.loginPage("/form/login") // 커스텀 로그인 페이지 설정
.successHandler((request, response, authentication) -> {
response.sendRedirect("/form"); // 로그인 성공 시 리다이렉트
})
.permitAll()) // 로그인 관련 요청 허용
1-4.. logout
로그아웃 관련 설정하는 부분이다. 로그아웃을 요청하는 URL을 설정하고, 로그아웃 성공 시 토큰이 담겨있는 쿠키를 삭제하고 인증 정보를 제거하는 설정을 해주었다.
.logout(logout -> logout
.logoutUrl("/logout") // 로그아웃 처리 URL 설정
.logoutSuccessUrl("/form/login") // 로그아웃 후 리다이렉트할 URL 설정
.deleteCookies("JWT", "AccessToken", "JSESSIONID") // 로그아웃 시 삭제할 쿠키들 설정
.clearAuthentication(true) // 로그아웃 시 인증 정보 제거
.permitAll()) // 로그아웃 관련 모든 요청 허용
1-5.. addFilterBefore
Spring Security의 필터 체인에서 특정 필터를 지정된 필터 앞에 추가하는 역할이다. 이를 통해 특정 요청이 필터 체인을 따라 처리되는 순서를 제어한다. (클래스를 생성하여 활용한다.)
- JwtAuthorizationFilter: JWT 토큰을 검증하고, 유효한 토큰이 있는 경우 사용자의 인증 정보를 SecurityContext에 저장하는 역할을 한다.
- LoginPageFilter: 로그인한 유저가 다시 로그인 페이지로 이동하려고 하는 것을 방지하는 역할을 한다.
.addFilterBefore(new JwtAuthorizationFilter(jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new LoginPageFilter(jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
2. JwtTokenProvider 작성
Spring Security와 JWT를 사용한 인증 및 권한 관리를 구현하기 위해 이 클래스를 작성했다. Access, Refresh 토큰을 생성하고 복호화, 검증, 인증 객체로 변환하는 작업을 처리한다. 이를 통해 클라이언트와 서버 간의 인증 상태를 유지하고, 인증된 사용자의 권한을 관리할 수 있다.
generateToken 메서드는 인증정보를 기반으로 Access, Refresh토큰을 생성한다. Access 토큰은 클라이언트가 API를 요청하면 사용되는 토큰으로 특정 기간 동안 유효하다. Refresh 토큰은 Access 토큰이 만료된 후 새로운 Access 토큰을 발급하기 위해 사용하는 토큰이다.
getAuthentication 메서드는 Access 토큰을 복호화하여 토큰에 담긴 사용자 정보를 Authentication 객체로 변환한다. UserDetails 객체로 사용자 정보를 추출하고 이 정보를 UsernamePasswordAuthenticationToken으로 래핑 하여 반환한다. 이 메서드를 통해 서버는 토큰을 검증하고 토큰에 포함된 사용자 정보로 인증을 처리할 수 있다.
validateToken 메서드는 토큰이 유효한지 검증하는 메서드이다.
parseClaims 메서드는 토큰을 복호화하여 내부의 클레임을 추출하는 메서드이다. 주로 토큰의 만료 여부 확인, 사용자 정보 추출 등에 사용된다.
getExpiration 메서드는 토큰의 만료시간을 확인하는 메서드이다. 토큰의 만료시점까지 남은 시간을 계산할 수 있다.
package kr.co.vibevillage.jwt.provider;
import kr.co.vibevillage.user.model.dto.UserDTO;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import lombok.extern.slf4j.Slf4j;
import java.util.Date;
import java.util.stream.Collectors;
import java.util.Collection;
import java.util.Arrays;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@Slf4j
public class JwtTokenProvider { // JWT 토큰 생성, 토큰 복호화 및 추출, 토큰 유효성 검증 기능
// JWT 토큰의 클레임(Claims)에서 권한 정보를 저장할 때 사용하는 키. 이 키를 사용하여 권한 정보를 JWT 토큰에 저장하거나, 토큰에서 권한 정보를 추출할 때 활용
private static final String AUTHORITIES_KEY = "auth";
// HTTP Authorization 헤더에서 사용되는 인증 타입인 Bearer 타입을 나타내는 상수. JWT 토큰은 일반적으로 "Bearer {토큰}" 형식으로 사용되며, 이 상수는 그 타입을 명시한다.
private static final String BEARER_TYPE = "Bearer";
// Access Token의 유효 시간을 정의하는 상수. 이 시간이 지나면 토큰이 만료된다. (30분)
private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;
// Refresh Token의 유효 시간을 정의하는 상수. Access Token이 만료되었을 때, 새로운 Access Token을 발급받기 위해 사용. (7일)
private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;
// security의 Key 클래스를 이용한 객체 생성
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
// BASE64를 이용한 디코딩
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
// 주어진 비밀 키(secretKey)사용, HMAC-SHA 알고리즘을 위한 Key 객체를 생성
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public UserDTO.TokenResDto generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return UserDTO.TokenResDto.builder()
.grantType(BEARER_TYPE)
.refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
// 사용자 계정이 null이거나 비어있는지 확인
String userId = claims.getSubject();
if (userId == null || userId.trim().isEmpty()) {
throw new IllegalArgumentException("userId cannot be null or empty");
}
// 권한 정보가 null이거나 비어있는지 확인
String authorityClaim = claims.get(AUTHORITIES_KEY, String.class);
if (authorityClaim == null || authorityClaim.trim().isEmpty()) {
throw new IllegalArgumentException("Authorities cannot be null or empty");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(authorityClaim.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(userId, "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
// 주어진 JWT 액세스 토큰을 복호화하여 토큰에 포함된 클레임(Claims) 정보를 추출하는 메서드
private Claims parseClaims(String accessToken) {
try {
// JWT 토큰을 복호화하고, 그 결과로 클레임(Claims) 객체를 반환
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
// JWT 토큰이 만료된 경우 ExpiredJwtException이 발생하므로, 이 예외가 발생하면 만료된 클레임을 반환
return e.getClaims();
}
}
// 주어진 JWT 액세스 토큰의 남은 유효시간을 계산하는 메서드.
public Long getExpiration(String accessToken) {
// accessToken 남은 유효시간 추출
Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
// 현재 시간
Long now = new Date().getTime();
// 만료 시간에서 현재 시간을 뺀 값을 반환, 즉 남은 유효시간을 계산하여 반환
return (expiration.getTime() - now);
}
}
3.JwtAuthorizationFilter 작성
이 클래스는 요청이 들어올 때마다 JWT를 확인하고, 유효한 토큰이 있다면 인증 정보를 설정하여 사용자 권한을 관리하는 역할을 한다.
GenericFilterBean 클래스를 구현하고 있는데, 이 클래스는 Spring Framwork에서 제공하는 추상 클래스로 javax.servlet.Filter 인터페이스를 편리하게 구현할 수 있도록 도와주는 역할을 한다. 개발자가 필터 로직에만 집중할 수 있게 도와주는 편리한 클래스이다.
doFilter 메서드를 오버라이딩하여 JWT 추출부터 유효성 검사까지 진행한다.
package kr.co.vibevillage.jwt.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import kr.co.vibevillage.jwt.provider.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.Authentication;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import java.io.IOException;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends GenericFilterBean {
private static final String AUTHORIZATION_HEADER = "Authorization"; // 요청 헤더에서 JWT 토큰을 찾기 위한 헤더 이름
private static final String BEARER_TYPE = "Bearer"; // JWT 토큰이 Bearer 타입임을 나타내는 접두사
private static final String JWT_COOKIE_NAME = "JWT"; // 쿠키에서 JWT 토큰이 저장된 이름
// JwtTokenProvider JWT 토큰 생성, 토큰 복호화 및 추출, 토큰 유효성 검증 기능을 하는 클래스
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출 (먼저 헤더에서 시도)
String token = resolveToken((HttpServletRequest) servletRequest);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 유효한 토큰이라면, Authentication 객체 생성
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// 3. SecurityContextHolder에 Authentication 객체를 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
log.info("Token is null or invalid");
}
// 4. 다음 필터 체인으로 요청 전달
filterChain.doFilter(servletRequest, servletResponse);
}
// Request Header 또는 쿠키에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
// 1. 헤더에서 토큰 시도
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7); // "Bearer "를 제거하고 토큰만 반환
}
// 2. 쿠키에서 JWT 토큰을 시도
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
// 쿠키에서 JWT 토큰을 찾으면 반환
return cookie.getValue();
}
}
}
// 토큰이 없다면 null 반환
return null;
}
}
4. LoginPageFilter 작성
이 클래스는 유저가 로그인한 상태에서 다시 로그인 페이지에 접근하는 것을 방지하기 위해 작성했다. 주요 코드로는 AntPathRequestMAtcher가 있는데, 주어진 URL 패턴과 현재 요청의 URL이 일치하는지 확인하는 역할을 한다. 또 SecurityContextHolder 메서드를 활용하여 인증 정보가 있으면 사용자가 로그인 상태라는 것을 알 수 있게 처리하였다.
doFilterinternal 메서드는 OncePerRequestFilter 클래스에서 구현해야 하는 메서드로, 요청이 들어올 때마다 메서드가 실행되며 요청의 처리 로직을 구현할 수 있다. 처리 로직으로는 현재 사용자가 이미 인증된 상태라면, 로그인 페이지로의 접근을 막고 다른 페이지로 넘기고 있다. (if문) OncePerRequestFilter 클래스는 Spring에서 제공하는 추상 클래스로 필터가 요청당 한 번만 실행되도록 보장한다.
package kr.co.vibevillage.jwt.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.vibevillage.jwt.provider.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component // 이 클래스가 Spring의 Bean으로 등록되도록 하는 어노테이션
@RequiredArgsConstructor // final 필드에 대해 자동으로 생성자를 생성해주는 Lombok 어노테이션
public class LoginPageFilter extends OncePerRequestFilter {
// JwtTokenProvider JWT 토큰 생성, 토큰 복호화 및 추출, 토큰 유효성 검증 기능을 하는 클래스
// JWT 토큰을 관리하는 JwtTokenProvider 인스턴스를 주입받음
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// "/form/login" URL로 들어온 요청인지 확인하는 조건문
// AntPathRequestMatcher를 이용해 특정 패턴의 URL과 매칭되는지 확인
if (new AntPathRequestMatcher("/form/login").matches(request)) {
// SecurityContextHolder에서 현재 인증된 사용자 정보를 가져옴
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 사용자가 인증되었는지(로그인 상태인지)와 익명 사용자인지 확인하는 조건문
if (authentication != null && authentication.isAuthenticated()
&& !authentication.getPrincipal().equals("anonymousUser")) {
// 사용자가 이미 로그인된 상태라면, 로그인 페이지에 접근하지 못하도록 메인 페이지("/form")로 리디렉션
response.sendRedirect("/form");
return;
}
}
// 위의 조건에 해당되지 않는 경우(즉, 로그인되지 않은 상태에서 로그인 페이지에 접근하는 경우),
// 요청을 다음 필터로 넘김
filterChain.doFilter(request, response);
}
}
5. UserDTO 코드 추가
Spring Security와 JWT 기반의 인증 및 권한 관리를 간편하게 구현하고자 UserDTO 클래스에 코드를 추가해 주었다.
toAuthenticationToken 메서드는 UserDTO 객체를 UsernamePasswordAuthenticationToken 객체로 변환하여 Spring Security의 인증 과정에서 사용할 수 있도록 한다. Spring Security에서는 인증을 위해 Authentication 객체가 사용되는데 UsernamePasswordAuthenticationToken은 Authentication 인터페이스의 구현체 중 하나로 유저가 입력한 계정과 비밀번호를 사용한 인증을 처리한다.
TokenResDto 클래스는 JWT를 사용하여 생성된 인증 토큰과 관련된 데이터를 담기 위한 DTO 클래스이다. @Builder를 추가하여 객체 생성 시 필수적인 정보만 전달할 수 있도록 하여 코드의 가독성과 유지보수성을 높였다.
authorities 변수는 사용자의 권한(role)을 저장하는 역할을 한다. Spring Security는 권한을 기반으로 특정 URL이나 메서드에 대한 접근을 제어한다.
getAuthorities 메서드는 authorities 필드가 NULL 일경우, 빈 리스트로 초기화하여 NullPointerException을 방지한다.
setAuthorities 메서드는 외부에서 권한 정보를 설정할 수 있도록 한다.
이러한 변수와 메서드를 통해 UserDTO 객체는 Spring Security의 UserDetails 구현체와 유사한 역할을 하며, 사용자의 권한 정보를 쉽게 관리할 수 있도록 한다.
package kr.co.vibevillage.user.model.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@ToString
public class UserDTO {
.
.
.
(생략)
// UsernamePasswordAuthenticationToken 클래스 구현
public UsernamePasswordAuthenticationToken toAuthenticationToken() {
return new UsernamePasswordAuthenticationToken(userId, userPassword);
}
@Getter @Builder
public static class TokenResDto {
private String grantType;
private String accessToken;
private String refreshToken;
private Long refreshTokenExpirationTime;
}
// 권한 정보를 담는 필드 추가
private List<String> authorities;
// Getters and Setters
public List<String> getAuthorities() {
if (authorities == null) {
authorities = new ArrayList<>(); // authorities가 null일 경우 빈 리스트로 초기화
}
return authorities;
}
public void setAuthorities(List<String> authorities) {
this.authorities = authorities;
}
}
6. LoginController 수정
로그인 컨트롤러에서는 유저가 입력한 계정으로 DB에 암호화된 비밀번호를 가져오고, PasswordEncoder 인터페이스의 macthes 메서드를 활용하여 평문 비밀번호와 대조한다. 일치할 경우 JWT를 생성하고 생성된 토큰을 쿠키에 담아 브라우저에 전송한다.
package kr.co.vibevillage.user.controller;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.vibevillage.jwt.config.JWTConfig;
import kr.co.vibevillage.jwt.provider.JwtTokenProvider;
import kr.co.vibevillage.user.model.dto.UserDTO;
import kr.co.vibevillage.user.model.service.LoginServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Controller
@RequiredArgsConstructor // 초기화 되지 않은 final 필드나 @NonNull이 붙은 필드에 대한 생성자를 만들어줌
public class LoginController {
// 서비스 객체 생성
private final LoginServiceImpl loginService;
// 비밀번호 비교를 위한 passwordEncoder 객체 생성
private final PasswordEncoder passwordEncoder;
// JWT 생성을 위한 객체 생성
private final JWTConfig jwt;
// AccessToken, RefreshToken 생성을 위한 객체 생성
private final JwtTokenProvider jwtTokenProvider;
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration_time}")
private Long expiredMs;
@GetMapping("/login")
public String login(@RequestParam("userId") String userId, @RequestParam("userPassword") String userPassword,
HttpServletResponse response, Model model) {
log.info("--------------------------logincontroller-------------------------");
// DTO 객체 생성 및 사용자 ID 설정
UserDTO userDTO = new UserDTO();
userDTO.setUserId(userId);
// 데이터베이스에서 암호화된 비밀번호 가져오기
String getPassword = loginService.login(userId, userPassword);
// 기본 권한 설정
List<String> authorities = new ArrayList<>();
userDTO.setAuthorities(authorities);
authorities.add("ROLE_USER"); // 기본적으로 "ROLE_USER" 권한을 추가
// 평문 비밀번호와 암호화된 비밀번호 비교
if (passwordEncoder.matches(userPassword, getPassword)) {
// JWT 생성
String token = jwt.createJwt(userDTO, secretKey, expiredMs);
log.info("JWT: " + token);
// JWT로부터 Authentication 객체 생성
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// Authentication 객체를 SecurityContextHolder에 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
// JWT 쿠키에 저장
Cookie jwtCookie = new Cookie("JWT", token);
jwtCookie.setHttpOnly(true);
jwtCookie.setPath("/");
response.addCookie(jwtCookie);
return "redirect:/form";
} else {
return "redirect:/error";
}
}
}
7. 로그인 작업해 보기
로그인을 진행하여 토큰이 잘 생성되는지 먼저 확인해 보자.
로그인하고 나면 개발자도구 > 네트워크 > 쿠키 배너에 쿠키가 생성되고 그 안에 토큰이 생성되어 있는 것을 확인할 수 있다. 그리고 로그인 완료되고 나서 메인 페이지로 이동도 잘 된다. 시행착오도 많고 우여곡절 많았지만 얼른 기능을 구현해야 한다는 집념 때문에 발생했던 오류와 수정과정을 하나도 기록하지 못했다. 기능 구현을 하면서 포스팅하는 습관을 들여야겠다.... 다음 포스팅에는 로그인, 로그아웃 기능의 부가적인 기능(로그인하고 나면 로그인 버튼이 로그아웃으로 바뀌는 기능, 다른 팀원들이 회원 정보를 추출하여 활용할 수 있는 기능)에 대해 작성해 보도록 하겠다.
'개발 > Team Project' 카테고리의 다른 글
ep13. 회원 프로필 업로드 (2) | 2024.09.06 |
---|---|
ep.12 JWT에서 정보를 추출하여 로그인한 회원 확인하기 (0) | 2024.08.21 |
ep10. 로그인 기능 구현 (feat. SpringSecurity 초기 설정 + JWT 생성) (0) | 2024.08.14 |
ep.09 비밀번호 암호화 (Bcrypt) (0) | 2024.08.13 |
ep08. 본인인증 (SpringBoot + Coolsms + Redis) (0) | 2024.08.09 |