인증과 인가
- 인증(Authentication) : 사용자가 본인 맞는지(누구인지) 확인
- 인가(Authorization) : 인증된 사용자가 특정 장소(자원)에 접근 가능한지 확인 (권한 확인)
spring security
spring security는 애플리케이션의 인증과 인가를 담당하는 스프링 프레임워크다. Dispatcher Servelet에 요청이 돌아오기 전에 Filter에서 인증과 인가를 수행한다. controller에서는 요청에 대한 응답만을 제공할 수 있다.
Filter는 Dispatcher Servelet 전에 적용, 가장 먼저 url 요청 받음
Interceptor는 Dispatcher와 Controller 사이에 위치
- principal (접근 주체, 아이디) : 자원에 접근하는 대상
- credential (비밀번호) : 비밀번호
spring security에서는 principal을 아이디로, credential을 비밀번호로 사용한다.
동작 순서
- 인증 요청
- AuthenticationFilter가 요청을 가로채고 UsernamePasswordAuthentication Token(Authentication을 implements한 인증용 객체) 생성, 해당 요청 처리할 수 있는 provider 찾는데 사용
- AuthenticationManager에게 처리 위임 (List 형태로 Provider 가짐)
- Token 처리 가능한 AuthenticationProvider 선택, 인증 객체 전달
- DB의 사용자 정보와 로그인 정보 비교 위해 UserDetailsService에 사용자 정보 넘김
- UserDetailsService의 loadUserByUserName()으로 사용자 정보를 UserDetails 형식으로 가져옴 (존재하지 않으면 예외 던짐), AuthenticationProvider가 UserDetails 받고 사용자 정보 비교함 (password는 security에서 자동 비교)
- 인증 완료되면 Authentication 객체를 SecurityContextHolder에 담고 AuthenticationSuccessHandle 실행
사용자 정보 저장 = 세션 - 쿠키 기반의 인증 방식 사용
JWT 사용
jwt filter를 만들어 UsernamePasswordAuthenticationFilter 전에 넣어 authentication을 SecurityContextHolder에 담아준다.
build.gradle
//security, jwt
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
//@EnableGlobalMethodSecurity(securedEnabled = true) //controller api 별로 다르게 적용하고 싶을 때 사용
public class SecurityConfig {
private final JwtFilter jwtFilter;
private final ExceptionHandlerFilter exceptionHandlerFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.httpBasic().disable()//문자열 Base64로 인코딩
.csrf().disable() //쿠키 기반 x -> 사용 x
.cors().and()
.formLogin().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 생성, 사용 x
.and()
.authorizeRequests()
//swagger, test 관련 모든 권한 승인
.antMatchers(
"/swagger-ui/**", "/api-docs/**", "swagger-resources/**", "/api/test/**"
).permitAll()
//join, login, reissue 시 권한 승인
.antMatchers("/api/members/join", "/api/members/login", "/api/members/reissue").permitAll()
//admin 권한 허용
.antMatchers("/api/admin/**").hasRole("ADMIN")
//그 외 모든 member 권한 허용
.antMatchers("/api/**").hasAnyRole("OWNER", "ADMIN")
.anyRequest().denyAll()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, JwtFilter.class)
.exceptionHandling()
.accessDeniedHandler(new AccessDeniedHandler() { //권한 문제
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
throw new CustomSecurityException(ErrorCode.PERMISSION_DENIED);
}
})
.authenticationEntryPoint(new AuthenticationEntryPoint() { //인증 문제
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
throw new CustomSecurityException(ErrorCode.INVALID_TOKEN);
}
});
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
JwtFilter
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//헤더에서 토큰 가져오기
String token = jwtProvider.resolveToken(request);
if (token != null && jwtProvider.isValidToken(token)) { //유효한지 확인
//권한 추출
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
JwtProvider
@Component
public class JwtProvider {
private final byte[] SECRET_KEY;
private final Long ACT_EXPIRE_TIME;
private final Long RFT_EXPIRE_TIME;
public final static String AUTHORIZATION = "Authorization";
public final MemberRepository memberRepository;
public JwtProvider(@Value("${jwt.token.secret-key}") String SECRET_KEY,
@Value("${jwt.token.access-expiration}") Long ACT_EXPIRE_TIME,
@Value("${jwt.token.refresh-expiration}") Long RFT_EXPIRE_TIME,
MemberRepository memberRepository) {
this.SECRET_KEY = SECRET_KEY.getBytes();
this.ACT_EXPIRE_TIME = ACT_EXPIRE_TIME;
this.RFT_EXPIRE_TIME = RFT_EXPIRE_TIME;
this.memberRepository = memberRepository;
}
//jwt 토큰 발급
public JwtToken issue(String email, Role role){
return JwtToken.builder()
.accessToken(createAccessToken(email, role))
.refreshToken(createRefreshToken())
.build();
}
//토큰 재발급 (refresh token 이용)
public String reissue(String accessToken, String refreshToken){
isValidToken(refreshToken); //토큰 만료/유효성 확인
String recreatedAccessToken = recreateAccessToken(accessToken);
return recreatedAccessToken;
}
//access token 생성
public String createAccessToken(String email, Role role){
Claims claims = Jwts.claims();
claims.put("email", email);
claims.put("role", role.getRole());
return Jwts.builder()
.setSubject("UserInfo")
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + (ACT_EXPIRE_TIME)))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
//refresh token 생성
public String createRefreshToken(){
return Jwts.builder()
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + (RFT_EXPIRE_TIME)))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
public String recreateAccessToken(String accessToken){
String email;
Role role;
try{
Jws<Claims> claimsJws = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(accessToken);
Claims body = claimsJws.getBody();
email = String.valueOf(body.get("email"));
role = Role.of((String) body.get("role"));
} catch (ExpiredJwtException e) {
email = String.valueOf(e.getClaims().get("email"));
role = Role.of((String) e.getClaims().get("role"));
}
return createAccessToken(email, role);
}
//토큰에서 Authentication 객체 반환
public Authentication getAuthentication(String accessToken){
Jws<Claims> claims = getClaims(accessToken);
Claims body = claims.getBody();
String email = String.valueOf(body.get("email"));
Role role = Role.of((String) body.get("role"));
if(!memberRepository.existsByEmail(email)){
throw new CustomJwtException(ErrorCode.JWT_ERROR, "유효하지 않은 토큰입니다.");
}
return new CustomAuthentication(email, role);
}
//사용자가 보낸 Authorization 필드에서 토큰 추출
public String resolveToken(HttpServletRequest request) {
return request.getHeader(AUTHORIZATION);
}
//토큰 유효성 확인, get claims
public boolean isValidToken(String token){
if(token== null) throw new CustomJwtException(ErrorCode.JWT_ERROR, "빈 값입니다.");
getClaims(token);
return true;
}
public String getMemberEmail(String accessToken){
String email;
try{
Jws<Claims> claimsJws = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(accessToken);
Claims body = claimsJws.getBody();
email =String.valueOf(body.get("email"));
} catch (ExpiredJwtException e) {
email = String.valueOf(e.getClaims().get("email"));
}
return email;
}
private Jws<Claims> getClaims(String token){
try{
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token);
} catch (ExpiredJwtException e){ //유효기간 만료
throw new CustomJwtException(ErrorCode.JWT_EXPIRED_ERROR);
} catch (JwtException e){ //그 외 jwt 오류
// log.info("e: {}", e);
throw new CustomJwtException(ErrorCode.JWT_ERROR);
}
}
}
CustomAuthentication
@Getter
@RequiredArgsConstructor
public class CustomAuthentication implements Authentication {
private final String email;
private final Role role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(()->role.getRole());
return authorities;
}
@Override
public Object getCredentials() {
return email;
}
@Override
public Object getDetails() {
return email;
}
@Override
public Object getPrincipal() {
return email;
}
@Override
public boolean isAuthenticated() {
return true;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
}
@Override
public String getName() {
return null;
}
}
Role 구현 시 하나의 사용자는 하나의 권한만 가질 수 있을 것 같은데 list로 구현한 사례가 굉장히 많았다. list로 구현하지 않고 config에서 여러 권한을 지정해 주게 구현했다. list가 되어야 하는 이유가 와닿지 않아서 수정하지 않았는데 이유가 궁금하다.