전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.
이번에 프로젝트를 마무리하면서 사용했던 기술들을 정리해 보는
시간이 필요할 것 같아 스프링 시큐리티부터 정리해 보겠다.
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
testImplementation 'org.springframework.security:spring-security-test'
우선 gradle 파일에 시큐리티와 JWT 관련 의존성을 추가해 준다.
(OAuth는 어차피 나중에 사용할 것이기 때문에 미리 넣어두었다)
엔티티 매핑
데이터베이스 설정은 H2나 MySQL 혹은 다른 사용 중이던
데이터베이스에 맞게 각자 설정해 준다.
@Builder
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
private String nickname;
private String email;
private String password;
private Authority authority;
}
회원 엔티티가 있어야
회원가입을 하고 로그인 정보를 검증할 테니
회원 엔티티부터 만들어준다.
@AllArgsConstructor
public enum Authority implements BaseEnum {
ROLE_ADMIN("admin"),
ROLE_USER("user");
private final String description;
@Override
public String getName() {
return name();
}
@Override
public String getDescription() {
return this.description;
}
}
권한을 지정할 때 사용할 enum 클래스도 하나 추가해 준다.
이때 필드값에 어떤 것이 들어가던 상관없지만
ROLE_권한명 양식은 지켜서 만들어야 한다.
스프링 시큐리티는 ROLE_ 접두사가 붙은 권한을
기본적으로 인식하기 때문에 양식을 지켜야 한다.
(일관성만 유지한다면 붙이지 않고도 사용은 가능하다)
로그인 정보 조회 클래스
데이터베이스에서 로그인 정보를 조회해
요청값과 일치하는지 비교할 때 사용할
UserDetails와 UserDetailsService를 구현할 것이다.
@Getter
public class CustomUserDetails implements UserDetails {
private Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(
new SimpleGrantedAuthority(member.getAuthority().toString())
);
}
@Override
public String getUsername() {
return member.getEmail();
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public boolean isAccountNonExpired() {
return true;
} // 계정 만료 여부 (계정의 유효기간이 지난 경우)
@Override
public boolean isAccountNonLocked() {
return true;
} // 계정 잠금 여부 (여러 번의 로그인 실패 시)
@Override
public boolean isCredentialsNonExpired() {
return true;
} // 계정의 비밀번호가 만료되었는지 (비밀번호 변경 주기가 지난 경우)
@Override
public boolean isEnabled() {
return true;
} // 계정이 일시적으로 잠긴 상태인지 여부 (관리자에 의해 일시적으로 정지된 계정 같은 경우)
}
필드에 멤버 엔티티를 하나 선언한 후에
생성자로 받아와 알맞게 메서드들을 구현해주면 된다.
boolean 값들의 경우에는 일단 기본적으로 true로 설정해주었는데
만약 엔티티에 위의 값들을 저장할 필드를 추가한다면
member.get필드명으로 값을 받아오면 된다.
@Service
public class CustomUserDetailsService implements UserDetailsService {
private MemberRepository memberRepository;
public CustomUserDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username).orElseThrow();
return new CustomUserDetails(member);
}
}
레포지토리에서 파라미터의 username과 일치하는
회원을 찾아 위에서 만든 CustomUserDetails 객체를 생성해준다.
이메일과 일치하는 회원이 없는 경우에는
이메일 즉, 로그인 아이디(username)가 잘못된 경우니
아이디가 틀렸다는 등의 예외를 발생시켜도 된다.
이렇게 클래스들을 만들어두면
로그인 요청이 들어왔을 때 AuthenticationManager가
위의 클래스들을 사용해 로그인 정보와 실제 사용자 정보가
일치하는지 비교하여 로그인 성공 여부를 판단한다.
스프링 시큐리티 설정
스프링 시큐리티의 기본 설정값은
세션을 사용한 로그인 방식을 사용한다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.apply(new CustomFilterConfigurer()); // 필터 설정
return httpSecurity
.httpBasic(HttpBasicConfigurer::disable) // 1
.headers(getHeadersConfigurer()) // 2
.csrf(CsrfConfigurer::disable) // 3
.cors(getCorsConfigurer()) // 4
.sessionManagement(getSessionManagementConfigurer()) // 5
.exceptionHandling(getExceptionHandlingConfigurer()) // 6
.authorizeHttpRequests(getAuthorizeHttpRequestsConfigurer()) // 7
.build();
}
}
우선 SecurityConfig 클래스를 생성한 후에
위와 같이 작성합니다.
설정에 사용된 메서드들은 잠시 뒤에 알아보고
어떤 설정들을 적용했는지 알아보겠습니다.
1. httpBasic 옵션은 기본적인 인증 방식으로
보안상 좋지 않기 때문에 비활성화합니다.
2. H2 DB를 사용하기 위해 해둔 설정이라
필수는 아니기에 생략 가능합니다.
3. csrf 옵션은 원래는 기본적으로 CSRF 토큰을 받은
요청만 수행하게 되어있지만 로컬에서 테스트하기 편하게
비활성화해두었습니다.
4. cors 옵션은 로컬에서만 테스트할 거라면
굳이 설정할 필요는 없지만 일단은
모든 URL에 대해 요청을 열어놨습니다.
5. 토큰 로그인 방식을 사용할 것이기 때문에
세션의 상태를 유지하지 않게 설정했습니다.
6. 인증 예외에 대한 처리를 설정합니다.
7. API 요청에 필요한 인증 및 인가의 수준을 설정합니다.
public class Customizers {
// 설정을 반환할 메서드들
}
필터체인에 람다식으로 설정을 적용하거나
SecurityConfig 클래스 내에 메서드를 만들어도 상관없지만
가독성과 클래스의 역할을 구분하기 위해
따로 메서드와 클래스로 분리하였다
접근 제어자의 경우에는 Config 클래스와 같은 패키지라 default로 했지만
만약 경로가 다르다면 적절하게 바꿔줘야 한다.
static Customizer<CorsConfigurer<HttpSecurity>> getCorsConfigurer() {
return cors -> {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.addAllowedMethod("*");
configuration.addAllowedHeader("*");
configuration.setAllowCredentials(true);
configuration.setExposedHeaders(List.of(AUTHORIZATION, REFRESH, LOCATION));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
cors.configurationSource(source);
};
}
CORS 설정으로 모든 HTTP 메서드와 헤더에 대해 요청을 허용하고
일단 허용 주소는 프런트 측이 로컬에서 자주 사용하는 3000번 포트로 열어놨다.
(그냥 "*"을 적어놔도 상관없다)
자격 증명 요청 여부도 true로 설정하고
헤더에 액세스 토큰, 리프래시 토큰, 리디렉트 등을 노출하도록 했다.
마지막으로 이 설정들을 어떤 url들에 적용할지 정하는데
모든 url에 적용되게 해 놨다.
나중에 프런트와 백엔드가 통합된 환경에서만 필요한 설정이라
로컬에서 혼자 연습만 할 거라면
굳이 cors 설정은 해줄 필요 없다.
static Customizer<SessionManagementConfigurer<HttpSecurity>> getSessionManagementConfigurer() {
return sessionManagementConfigurer -> new SessionManagementConfigurer<>()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
세션 로그인을 사용하지 않을 것이기 때문에
세션 정책을 상태를 유지하지 않게 바꿔준다.
static Customizer<ExceptionHandlingConfigurer<HttpSecurity>> getExceptionHandlingConfigurer() {
return exceptionHandlingConfigurer -> new ExceptionHandlingConfigurer<>()
.authenticationEntryPoint(getCustomAuthenticationEntryPoint())
.accessDeniedHandler(getCustomAccessDeniedHandler());
}
인증 및 인가 실패 시 어떻게 할지 설정해 주는 부분으로
인증에 실패하거나 인가에 실패할 시
어떤 동작들을 실행할지 지정해 준다.
static AuthenticationEntryPoint getCustomAuthenticationEntryPoint() {
return new CustomAuthenticationEntryPoint();
}
static AccessDeniedHandler getCustomAccessDeniedHandler() {
return new CustomAccessDeniedHandler();
}
사용된 메서드들은 그냥 직접 만든 클래스들을 리턴하는 메서드다.
// AuthenticationEntryPoint
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
}
}
// AccessDeniedHandler
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
}
}
각각의 클래스들은 위와 같이 생겼고
오버라이딩한 메서드 안에 자신이 실행시키고 싶은
로직을 추가하면 된다.
(필수는 아니지만 해두면 좋다)
static Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> getAuthorizeHttpRequestsConfigurer() {
return auth -> auth
.requestMatchers(
new AntPathRequestMatcher("/"),
new AntPathRequestMatcher("/h2-console/**"),
new AntPathRequestMatcher("/profile")
).permitAll()
.anyRequest().permitAll();
}
어떤 요청들에 어떤 권한이 필요하고
권한이 필요 없는지 등을
지정해 줄 수 있는 설정이다.
자신이 인증 및 인가가 필요한 API 설정에 맞게
커스텀하여 사용하면 되고
스프링 부트 2.7.1, 시큐리티 5 버전까지는 문제가 없었지만
위와 같이 new AntPathRequestMatcher() 형식을
명시적으로 지정해주지 않으면 h2 콘솔 사용 시
예외가 발생한다.
지금처럼 API가 별로 없는 경우에는
따로 분리할 필요 없이
필터체인 메서드 안에서 람다식으로 해도 괜찮지만
API가 많아져 설정이 복잡해진다면
위와 같이 메서드로 분리하는 것이 깔끔하다.
커스텀 필터 설정
이제 마지막 설정으로는
로그인 정보를 확인하고 액세스 토큰을 발급하는 필터와
액세스 토큰 만료 시 리프래시 토큰으로 재발급하는 필터,
액세스 토큰이 유효한지 확인하는 필터를 추가하는 작업을 할 것이다.
private class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtProvider, authenticationManager);
jwtAuthenticationFilter.setFilterProcessesUrl(LOGIN_URL);
// jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
// jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());
JwtRefreshFilter jwtRefreshFilter = new JwtRefreshFilter(jwtProvider);
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtProvider);
builder
.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtRefreshFilter, JwtAuthenticationFilter.class)
.addFilterAfter(jwtVerificationFilter, JwtRefreshFilter.class);
}
}
아직 필터 클래스를 구현하진 않았지만
위와 같은 필터들을 구현할 것이기에
컴파일 에러가 뜨더라도 적어만 둔다.
성공 및 실패 핸들러는 빼도 상관 없다.
필터의 순서는 필터 체인 설정에 보이는 것처럼
인증 필터, 리프래시 필터, 검증 필터 순으로 넣어준다.
토큰 생성 및 검증 클래스
이제 구현해야 하는 클래스의 기능은 크게 세 가지다.
1. 사용자의 로그인 정보를 토대로
액세스 토큰과 리프래시 토큰을 발급하기
2. 리프래시 토큰으로 액세스 토큰을 재발급하기
3. 토큰 검증하기
@Component
public class JwtProvider {
}
일단 위와 같은 클래스를 새로 만든 후에
위에서 정리한 기능들을 구현한다.
@Value("${jwt.key}")
private String key;
private Key secretKey;
@PostConstruct
protected void init() {
secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
}
key는 JWT 토큰 서명에 사용될 암호화 하기 전의 키로
yml이나 properties 파일에 값을 저장한다.
(학습용이라면 변수에 직접 명시해 줘도 상관없다)
다음 필드인 secretKey는 암호화된 키가 저장될 변수로
init 메서드를 사용해 초기화를 해준다.
시크릿키는 실제 토큰 생성에 사용되는 값으로
BASE64 형식으로 디코딩된 키에
HMAC SHA 알고리즘을 적용한 값을 할당한다.
즉, key를 알아보기 어렵게 암호화한
secretKey로 토큰을 생성한다.
public String createAccessToken(Authentication authentication, Long tokenExpireTime) {
return Jwts.builder()
.setSubject(authentication.getName())
.claim(CLAIM_ID, getId(authentication))
.claim(CLAIM_AUTHORITY, getAuthorities(authentication))
.setExpiration(getExpiration(tokenExpireTime))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
}
다음은 액세스 토큰을 생성하는 메서드를 구현한다.
빌더 패턴을 사용하여
JWT 토큰의 구성을 설정해주는데
Authentication 객체의 getName 메서드는
사용자의 이메일을 가져오고
객체 안의 Principal에는 UserDetails가 들어있다.
claim에는 자신이 토큰에 포함하고 싶은
사용자의 정보를 추가해주면 되고
토큰의 만료시간과
토큰의 서명에 사용할 키를 지정해주면 된다.
보통 프런트가 필요한 데이터들을 claim에 넣어준다.
private Long getId(Authentication authentication) {
if(authentication.getPrincipal() instanceof CustomUserDetails customUserDetails){
return customUserDetails.getMember().getMemberId();
}
return null;
}
다음은 Claim에 넣어줄 회원의 ID를 가져오는 메서드로
여기서 ID는 이메일이 아니라 회원 테이블의 기본키다.
private String getAuthorities(Authentication authentication) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
}
비슷하게 해당 회원이 가진 권한도 Claim에 넣어준다.
private Date getExpiration(Long tokenExpireTime) {
return new Date(new Date().getTime() + tokenExpireTime);
}
토큰의 만료 시간도 현재 시간을 기준으로 설정해준다.
public String createRefreshToken(Authentication authentication, long tokenExpireTime){
return Jwts.builder()
.setSubject(authentication.getName())
.claim(CLAIM_ID, getId(authentication))
.setExpiration(getExpiration(tokenExpireTime))
.signWith(secretKey, SignatureAlgorithm.HS512)
.compact();
}
다음은 리프래시 토큰을 생성하는 메서드로
액세스 토큰과 비슷하지만
액세스 토큰 재발급에만 쓰일 토큰이기 때문에
굳이 권한과 같은 정보를 가지고 있을 필요가 없다.
그래서 만료시간 정도만 액세스 토큰보다
길게 설정하여 만들어주면 된다.
public String refillAccessToken(String refreshToken, long tokenExpireTime) {
Claims claims = getClaims(refreshToken);
String username = claims.getSubject();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
return createAccessToken(authentication, tokenExpireTime);
}
리프래시 토큰으로 액세스 토큰을
재발급 할 때 사용할 메서드다.
액세스 토큰을 만들 때 사용할
Authentication 객체를 만들기 위해
사용자의 이메일로 UserDetails를 가져온 후에
Authentication 객체를 만들고
이를 사용해 액세스 토큰을 재발급 받는다.
public Claims getClaims(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
throw new JwtExpiredException();
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
throw new JwtNotValidException();
}
}
위에서 사용한
토큰에서 Claims를 가져오는 메서드로
토큰을 비밀키로 복호화하는 과정에서
만료되거나 잘못된 토큰인 경우
예외가 발생한다.
위와 같이 간단하게 예외 클래스들을 만들어서
처리해줘도 괜찮고
귀찮다면 대충 런타임 예외를 던져버려도 상관없다.
(하지만 되도록이면 예외는 처리해주도록 하자)
public void validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw new JwtExpiredException();
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
throw new JwtNotValidException();
}
}
나중에 만들 토큰 검증 필터에서 사용할
토큰이 정상적인지 검증할 메서드로
Claims를 가져오는 메서드와
반환값의 차이만 있다.
시큐리티 설정과 토큰 생성 및 검증에 대한
구현을 모두 끝냈고
다음 게시글에서 필터를 구현해보겠다.
'Back-End > Security' 카테고리의 다른 글
OAuth2.0 인증 구현하기 (0) | 2023.09.24 |
---|---|
JWT 로컬 로그인 구현하기 - 2편 (0) | 2023.09.23 |
[스프링 시큐리티] 공식문서로 배워보기 : Username/Password - 2 (0) | 2023.08.15 |
[스프링 시큐리티] 공식문서로 배워보기 : Username/Password - 1 (0) | 2023.08.14 |
[스프링 시큐리티] 공식문서로 배워보기 : 인증 아키텍처 (0) | 2023.08.14 |