인증(Authentication)과 권한(Authorization)
이 두 영역은 사실상 스프링 시큐리티의 핵심이다.
인증(Authentication): 보호된 리소스에 접근하는 대상, 즉 사용자에게 적절한 접근 권한이 있는지 확인하는 일련의 과정을 의미한다. 이때 보호된 리소스에 접근하는 대상(사용자)을 접근 주체(Principal)이라고 한다.
권한(Authorization): 인증절차가 끝난 접근 주체가 보호된 리소스에 접근 가능한지를 결정하는 것을 의미한다. 이때 권한을 부여하는 작업을 인가(Authorize)라고 한다.
쉽게 말하면 인증은 아이디와 비밀번호를 입력 받아 로그인하는 과정 자체를 의미하는 것이고 권한이 필요한 리소스에 접근하기 위해서는 당연히 이러한 인증 과정을 거쳐야 한다. 스프링 시큐리티는 이런 인증 메커니즘을 간단하게 만들 수 있도록 다양한 옵션들을 제공하고 있다. 또한 스프링 시큐리티는 웹 요청이나 메서드 호출, 도메인 인스턴스에 대한 접근 등 상당히 깊은 수준의 권한 부여를 제공하고 있다.
스프링 시큐리티의 구조
스프링 시큐리티는 주로 서블릿 필터와 이들로 구성된 필터체인을 사용하고 있다.
실제 로그인 시에 스프링 시큐리티의 동작 플로우를 바탕으로 인증과 관련된 스프링 시큐리티의 아키텍처 살펴보자.
위 그림의 동작 플로우를 간단히 설명하면 다음과 같다.
1. 사용자가 로그인 정보와 함께 인증 요청(Http Request)
2. AuthenticationFilter가 이 요청을 가로챈다. 이때 가로챈 정보를 통해 UsernamePasswordAuthenticationToken이라는 인증용 객체를 생성한다.
3. AuthenticationManager의 구현체인 ProviderManager에게 UsernamePasswordAuthenticationToken 객체를 전달한다.
4. 다시 AuthenticationProvider에 UsernamePasswordAuthenticationToken 객체를 전달한다.
5. 실제 데이터베이스에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보(아이디)를 넘겨준다.
6. 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다. 이때 UserDetails는 인증용 객체와 도메인용 객체를 분리하지 않고 인증용 객체에 상속해서 사용하기도 한다.
7. AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보를 비교한다.
8. 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.
9. 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다.
10. Authentication 객체를 SecurityContext에 저장한다.
최종적으로 SecurityContextHolder는 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다.
세션에 사용자정보를 저장한다는 것은 스프링 시큐리티가 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.
스프링 시큐리티 필터
- SecurityContextPersistentFilter : SecurityContextRepository에서 SecurityContext를 가져와서 SecurityContextHolder에 주입하거나 반대로 저장하는 역할을 한다.
- LogoutFilter : logout 요청을 감시하며, 요청 시 인증 주체(Principal)를 로그아웃 시킨다.
- UsernamePasswordAuthenticationFilter : login 요청을 감시하며, 인증 과정을 진행한다.
- DefaultLoginPageGenerationFilter : 사용자가 별도의 로그인 페이지를 구현하지 않은 경우, 스프링에서 기본적으로 설정한 로그인 페이지로 넘어가게 한다.
- BasicAuthenticationFilter : HTTP 요청의 (BASIC) 인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장한다.
- RememberMeAuthenticationFilter : SecurityContext에 인증(Authentication) 객체가 있는지 확인하고 RememberMeServices를 구현한 객체 요청이 있을 경우, RememberMe를 인증 토큰으로 컨텍스트에 주입한다.
- AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 익명 사용자로 취급한다.
- SessionManagementFilter : 요청이 시작된 이후 인증된 사용자인지 확인하고, 인증된 사용자일 경우 SessionAuthenticationStrategy를 호출하여 세션 고정 보호 매커니즘을 활성화하거나 여러 동시 로그인을 확인하는 것과 같은 세션 관련 활동을 수행한다.
- ExceptionTranslationFilter : 필터체인 내에서 발생되는 모든 예외를 처리한다.
- FilterSecurityInterceptor : AccessDecisionManager로 권한부여처리를 위임하고 HTTP 리소스의 보안 처리를 수행한다.
JWT(Json Web Token)
기존의 인증 체계는 Cookie, Sessoin으로 이루어져 있다.
쿠키는 노출될 때, 민감정보가 전부 노출되어 보안상 좋지 않고, 쿠키 조작의 가능성, 다른 브라우저간 공유 불가능
적은 사이즈로 충분한 데이터를 담을 수 없기 때문에 인증에 사용하지 않는다.
세션은 저장소에 문제가 발생하면, 인증 체계가 무너지고, 이전에 다른 인증된 유저 또한 인증이 불가능해진다는 점,
stateful 하기 때문에, http의 장점을 발휘하지 못함, 사용자가 많아지면 메모리를 많이 차지하는 단점이 있다.
JWT(Json Web Token)은 JSON 객체를 통해 안전하게 정보를 전송할 수 있는 웹표준(RFC7519)이다. JWT는 '.'을 구분자로 세 부분으로 구분되어 있는 문자열로 이루어져 있다. 각각 헤더는 토큰 타입과 해싱 알고리즘을 저장하고, 내용은 실제로 전달할 정보, 서명에는 위변조를 방지하기 위한 값이 들어가게 된다.
API 서버는 로그인 요청이 완료되면 클라이언트에게 회원을 구분할 수 있는 정보를 담은 JWT를 생성하여 전달한다. 그러면 클라이언트는 이 JWT를 헤더에 담아서 요청을 하게 된다.
권한이 필요한 요청이 있을 때마다 API 서버는 헤더에 담긴 JWT 값을 확인하고 권한이 있는 사용자인지 확인하고 리소스를 제공하게 된다.
이렇게 기존의 세션-쿠키 기반의 로그인이 아니라 JWT 같은 토큰 기반의 로그인을 하게 되면 세션이 유지되지 않는 다중 서버 환경에서도 로그인을 유지할 수 있게 되고 한 번의 로그인으로 유저정보를 공유하는 여러 도메인에서 사용할 수 있다는 장점이 있다.
이때 회원을 구분할 수 있는 정보가 담기는 곳이 바로 JWT의 payload 부분이고 이곳에 담기는 정보의 한 '조각'을 Claim이라고 한다. Claim은 name / value 한 쌍으로 이루어져 있으며 당연히 여러 개의 Claim들을 넣을 수 있다.
시작하기
build.gradle
plugins {
id 'org.springframework.boot' version '3.0.1'
id 'io.spring.dependency-management' version '1.1.0'
id 'java'
}
group = 'com.examle'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.testng:testng:7.1.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
}
test {
useJUnitPlatform()
}
디펜던시를 보면 우리가 사용할 JWT, 시큐리티가 포함되어 있다.
Member 도메인
시큐리티 테스트를 위한 사용자 도메인을 만들어야 한다.
시큐리티 자체적으로 UserDetails의 구현체인 User를 사용하기 때문에, Memeber로 이름 지었다.
Member
@Entity
@Getter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String email;
private String password;
@Enumerated(EnumType.STRING)
private Authority authority;
@Builder
public Member(String email, String password, Authority authority) {
this.email = email;
this.password = password;
this.authority = authority;
}
}
이메일, 비밀번호, 권한만을 가지고 있다. 이메일로 로그인을 하도록 만들었다.
Authority
public enum Authority {
ROLE_USER,ROLE_ADMIN
}
유저와 어드민으로 권한을 분리하였다.
MemberRepository
public interface MemberRepository extends JpaRepository<Member,Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
}
Email로 로그인을 하기 때문에 중복 가입 방지와 존재여부를 파악하는 메서드를 추가하였다.
application.yml
spring:
h2:
console:
enabled: true
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
show_sql: true
logging:
level:
com.example: debug
jwt:
secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
Secret 값은 특정 문자열을 Base64로 인코딩한 값을 사용한다.
H2 데이터베이스를 사용하기 위한 기본설정과 JWT 시크릿 키를 설정해 준다.
JWT + Security 설정
- JWT 관련
- TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
- JwtFilter: Spring Request 앞단에 붙일 Custom Filter
- Spring Security 관련
- JwtSecurityConfig: JWT Filter를 추가
- JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
- JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
- SecurityConfig: 스프링 시큐리티에 필요한 설정
- SecurityUtil: SecurityContext에서 전역으로 유저 정보를 제공하는 유틸 클래스
- CorsConfig: 서로 다른 Server 환경에서 자원을 공유에 필요한 설정
TokenProvider
@Slf4j
@Component
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 151621022 (ex)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", 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("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
- JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어진다.
- 생성자
- application.yml 에 정의해 놓은 jwt.secret값을 가져와서 JWT를 만들 때 사용하는 암호화 키값을 생성합니다.
- generateTokenDto
- 유저 정보를 넘겨받아서 Access Token과 Refresh Token을 생성한다.
- 넘겨받은 유저 정보의 authentication.getName() 메서드가 username을 가져온다.
- username으로 Member ID를 저장했기 때문에 해당 값이 설정된다.
- Access Token 에는 유저와 권한 정보를 담고 Refresh Token 에는 아무 정보도 담지 않는다.
- getAuthentication
- JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼낸다.
- Access Token 에만 유저 정보를 담기 때문에 명시적으로 accessToken을 파라미터로 받는다.
- Refresh Token 에는 아무런 정보 없이 만료일자만 담았다.
- UserDetails 객체를 생생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext를 사용하기 위한 절차이다.
- validateToken
- 토큰 정보를 검증한다.
- Jwts 모듈이 알아서 Exception을 던진다.
JwtFilter
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal 에 들어감
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.split(" ")[1].trim();
}
return null;
}
}
- OncePerRequestFilter 인터페이스를 구현하기 때문에 요청받을 때 단 한 번만 실행된다.
- doFilterInternal
- 실제 필터링 로직을 수행하는 곳이다.
- Request Header에서 Access Token을 꺼내고 여러 가지 검사 후 유저 정보를 꺼내서 SecurityContext에 저장한다.
- 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거치기 때문에 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않는다.
- 그리고 요청이 정상적으로 Controller까지 도착했다면 SecurityContext에 Member ID 가 존재한다는 것이 보장된다.
- 대신 직접 DB를 조회한 것이 아니라 Access Token에 있는 Member ID를 꺼낸 거라서, 탈퇴로 인해 Member ID 가 DB에 없는 경우 등 예외 상황은 Service 단에서 고려해야 한다.
- resolveToken
- Request Header에서 토큰 정보를 꺼내오기 위한 메서드
JwtSecurityConfig
// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
// TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
- SecurityConfigurerAdapter <DefaultSecurityFilterChain, HttpSecurity> 인터페이스를 구현하는 구현체이다.
- 여기서 직접 만든 JwtFilter를 Security Filter 앞에 추가함
JwtAuthenticationEntryPoint
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
사용자 정보가 잘못되거나, 토큰이 유효하지 않은 경우에 대비하기 위한 클래스이다.(401 Unauthorized)
JwtAccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
필요한 권한이 존재하지 않은 경우, 403 Forbidden 에러를 리턴하기 위한 클래스이다.
ex) User권한을 가진 사람이 Admin 권한이 있는 곳에 접근하려 할 때 발생
CorsConfig
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
프론트엔드, 백엔드를 구분지어서 개발하거나, 서로 다른 Server 환경에서 자원을 공유할 때,
Cors설정이 안 되어있으면 오류가 발생한다. 이를 방지하기 위해 Cors 설정을 해주어야 한다.
- Credentials: 처리방식을 설정해 준다. 기본적으로 요청에 대한 응답으로 json타입이 나간다. 이를 Javascript에서 처리할 수 있게 해 준다. 이를 통해 프런트엔드에서 처리된 응답을 뷰에 맞게 설정해 줄 수 있다.
- Origin: ("*")는 모든 출처를 허용한다는 뜻이다.
- Header: ("*")는 모든 헤더를 허용한다는 뜻이다.
- Method: Get/Post/Delete.. 등 요청을 허용하는 방식, ("*")는 모든 방식을 허용한다는 뜻이다.
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests()
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
SecurityUtil
@Slf4j
public class SecurityUtil {
private SecurityUtil() { }
// SecurityContext 에 유저 정보가 저장되는 시점
// Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
public static Long getCurrentMemberId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("Security Context 에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
- JwtFilter에서 세팅한 유저 정보를 꺼낸다.
- memberId를 저장하게 했으므로 꺼내서 Long 타입으로 파싱 하여 반환한다.
- SecurityContext는 사용자의 정보를 저장한다.
Refresh Token
RefreshToken
@Getter
@NoArgsConstructor
@Entity
public class RefreshToken {
@Id
@Column(name = "rt_key")
private String key;
@Column(name = "rt_value")
private String value;
@Builder
public RefreshToken(String key, String value) {
this.key = key;
this.value = value;
}
public RefreshToken updateValue(String token) {
this.value = token;
return this;
}
}
- key 에는 Member ID 값이 들어간다.
- value 에는 Refresh Token String 이 들어간다.
RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByKey(String key);
}
- Member ID 값으로 토큰을 가져오기 위해 findByKey를 추가
사용자 인증
AuthController
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
return ResponseEntity.ok(authService.signup(memberRequestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto memberRequestDto) {
return ResponseEntity.ok(authService.login(memberRequestDto));
}
@PostMapping("/reissue")
public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
return ResponseEntity.ok(authService.reissue(tokenRequestDto));
}
}
- 회원가입 / 로그인 / 재발급을 처리하는 API이다.
- SecurityConfig에서/auth/** 요청은 전부 허용했기 때문에 토큰 검증 로직을 타지 않는다.
- MemberRequestDto 에는 사용자가 로그인 시도한 ID / PW String 이 존재한다.
- TokenRequestDto 에는 재발급을 위한 AccessToken / RefreshToken String 이 존재한다.
AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
throw new RuntimeException("이미 가입되어 있는 유저입니다");
}
Member member = memberRequestDto.toMember(passwordEncoder);
return MemberResponseDto.of(memberRepository.save(member));
}
@Transactional
public TokenDto login(MemberRequestDto memberRequestDto) {
// 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = memberRequestDto.toAuthentication();
// 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
// authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 4. RefreshToken 저장
RefreshToken refreshToken = RefreshToken.builder()
.key(authentication.getName())
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
// 5. 토큰 발급
return tokenDto;
}
@Transactional
public TokenDto reissue(TokenRequestDto tokenRequestDto) {
// 1. Refresh Token 검증
if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
}
// 2. Access Token 에서 Member ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
// 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
.orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
// 4. Refresh Token 일치하는지 검사
if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
}
// 5. 새로운 토큰 생성
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// 6. 저장소 정보 업데이트
RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(newRefreshToken);
// 토큰 발급
return tokenDto;
}
}
회원가입 (signup)
- ID, PW를 받아서 회원가입을 진행, ID의 경우 이미 존재하면 예외발생
로그인 (login)
- Authentication
- 사용자가 입력한 Login ID, PW로 인증 정보 객체 UsernamePasswordAuthenticationToken를 생성
- 아직 인증이 완료된 객체가 아니며 AuthenticationManager에서 authenticate 메서드의 파라미터로 넘겨서 검증 후에 Authentication를 받는다.
- AuthenticationManager
- 스프링 시큐리티에서 실제로 인증이 이루어지는 곳이다.
- authenticate 메서드 하나만 정의되어 있는 인터페이스며 위 코드에서는 Builder에서 유저 정보가 서로 일치하는지 검사한다.
- 인증이 완료된 authentication 에는 Member ID 가 들어있다.
- 인증 객체를 바탕으로 Access Token + Refresh Token을 생성한다.
- Refresh Token 은 저장하고, 생성된 토큰 정보를 클라이언트에게 전달한다.
재발급 (reissue)
- Access Token + Refresh Token을 Request Body에 받아서 검증한다.
- Refresh Token의 만료 여부를 먼저 검사한다.
- Access Token을 복호화하여 유저 정보 (Member ID)를 가져오고 저장소에 있는 Refresh Token과 클라이언트가 전달한 Refresh Token의 일치 여부를 검사한다.
- 만약 일치한다면 로그인했을 때와 동일하게 새로운 토큰을 생성해서 클라이언트에게 전달한다.
- Refresh Token 은 재사용하지 못하게 저장소에서 값을 갱신해준다.
CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}
// DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
return new User(
String.valueOf(member.getId()),
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
- loadUserByUsername 메서드를 오버라이드 하는데 여기서 넘겨받은 UserDetails와 Authentication의 패스워드를 비교하고 검증하는 로직을 처리한다.
- DB에 User값이 존재하면 UserDetails 객체를 만들어서 리턴한다.
API 호출 테스트
postman을 사용하였다.
회원가입
로그인
토큰 재발급
출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt
'Spring' 카테고리의 다른 글
@Controller와 @RestController 차이점 (0) | 2023.05.04 |
---|---|
Servlet, Spring MVC 정리 (0) | 2023.01.30 |
빈의 생명주기 (1) | 2023.01.03 |
컴포넌트 스캔과 의존 관계 자동 주입 (3) | 2023.01.02 |
스프링 컨테이너와 스프링 빈 (3) | 2023.01.01 |