ㅅㅇ
[SpringSecurity JWT] 인증인가 구현 - 회원가입, 로그인, 토큰을 통한 접근 본문
** 직접 공부하면서 작성 수정 중에 있는 내용이라, 틑린 내용 있을 수도 있습니다. 틀린 내용이 있다면 댓글 부탁드립니다.
** 전체 코드
https://github.com/cso6005/Auth_Project/tree/version1/UserProject
GitHub - cso6005/Auth_Project
Contribute to cso6005/Auth_Project development by creating an account on GitHub.
github.com
- maven project
- java 11
- spring boot 2.7.6
가능한 시나리오
** 리프레시 토큰, 토큰 재발급 내용 빠져있음.
- 회원가입
- 로그인
- 아이디 틀렸을 경우, 비밀번호 틀렸을 경우
- 로그인 성공
- 게시판 접근
- 로그인 상태(권한o, 토큰o)에서 게시판 접근
- 로그아웃 상태(권한o, 토큰x)에서 게시판 접근
- 로그인 상태에서 권한 없는 사용자가 게시판 접근
1. SecurityConfig
Spring Security 에 관한 설정은 해당 Config 파일에서 정해진다.
버전 별 차이가 있으니, 아래 공식 문서 참고
**
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
Spring | Home
Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.
spring.io
package io.csy.hot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import io.csy.hot.jwt.AccountDetailsService;
import io.csy.hot.jwt.JwtAuthenticationFilter;
import io.csy.hot.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
private final AccountDetailsService accountDetailsService;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler)
.authenticationEntryPoint(customAuthenticationEntryPoint)
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http
.authorizeRequests()
.antMatchers("/board/test").hasRole("USER")
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, accountDetailsService),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
0. 설정 파일 어노테이션 설정
- @Configuration :설정 파일을 뜻하는 어노테이션
- @EnableWebSecurity : Spring Security 설정 활성화을 뜻하는 어노테이션
1. PasswordEncoder : 비밀번호 암호화 빈 생성
2. AuthenticationManager : authenticationManager 빈 생성
스프링 부트 1점대 버전에서는 별도 처리 없이 SpringBoot에서 제공하는 AuthenticationManager를 주입시킬 수 있었다.
2점대 버전부터는 WebSecurityConfigurerAdapter를 상속받는 클래스에서 authenticationmanagerBean() 메서드를 오버라이드 해야 autowire가 가능하다.
3. SecurityFilterChain : 시큐리티 필터 설정
1. cors()
=> 컨트롤러에서 이미 설정해줬음. (모든 요청을 다 받겠다는 의미의 *으로 세팅)
2. csrf().disable()
요청을 위조하여 사용자의 권한을 이용해 서버에 대한 악성공격을 하는 것
SpringSecurity 에서는 이러한 CSRF에 의한 공격을 방지하기 위해 Form 형싟의 전송들에 대해 _csrf 라는 hidden 타입으로 값을 설정하고 이 값과 서버에서 받는 값이 일치해야 요청 전송을 허가한다.
=> 하지만 서버가 REST API 용도로만 사용된다면 설정이 필요없기에 disable 해준다.
3. 커스텀마이징 했던 핸들링 작동하게 예외처리 클래스 설정해주기
http.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler) // 인증 실패시 처리
.authenticationEntryPoint(customAuthenticationEntryPoint); // 인가 실패시 처리
3. 인가 권한 허용
http.authorizeRequests()
.antMatchers("/board/test").hasRole("USER")
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated();
- antMatchers
특정 리소스에 대해서 권한을 설정한다.
- hasRole()
/board/test 리소스의 경우, USER 권한 사용자에 경우에만 인가를 허용한다.
주의) hasRole() 의 경우, 자동으로 알아서 ROLE_ 을 붙여서 검사한다.
앞서, AccountDetailsService 에서 인증 객체에 ROLE_을 붙여 넣은 이유가 이것이다. 안 붙이고 그냥 USER, ADMIN 이렇게 넣으면 못찾는다.
- permitAll()
/auth로 시작하는 리소스에 대해서 인증절차 없이 허용한다는 의미 (그렇다고 시큐리티 필터를 거치지 않는 것이 아님.)
마지막 필터 단계를 살펴보면,
SecurityContextHolder에 있는 인증객체를 찾고 AccessDecisionManager에게 요청정보, 권한정보, 인증정보를 넘준다.
그리고 AccessDecisionManager에서는 넘겨받은 정보를 이용해 허용 여부를 판단하게 된다.
근데 이때, voter는 인증 되지 않는 정보여도 permitAll()이야!라는 판단과 함께 통과시켜주는 것이다.
그래서 무사히 디스패처서블릿으로 컨트롤러에 도달할 수 있다.
permitAll() 에 대해서 아래 링크 참고
https://github.com/cso6005/Auth_Project/issues/3
로그인/회원가입 요청의 경우, SecurityConfig에서 PermitAll() 설정, doFilter에서 헤더 조건문 추가 해주
SecurityConfig 설정 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors().and().csrf().disable() .exceptionHandling() .accessDeniedHandler(customAccessDeniedH...
github.com
- anyRequest().authenticated()
모든 리소스를 의미하며 접근 허용 리소스 및 인증 후 특정 레벨의 권한을 가진 사용자만 접근 가능한 리소스를 설정하고
그 외 나머지 리소스들은 무조건 인증을 완료해야 접근이 가능하게 한다.
4. 커스텀 필터 등록
http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, accountDetailsService),
UsernamePasswordAuthenticationFilter.class)
;
- addFilterBefore
지정된 필터 앞에 커스텀 필터를 추가해준다.
스프링시큐리티는 각각의 필터들이 체인형태로 구성되어있으며, 이는 순서에 맞게 실행된다.
이때, 인증을 처리하는 기본필터 UsernamePasswordAuthenticationFilter 대신 내가 별도로 만든 인증 로직 필터를 등록시켜준다.
사실, 대신이라는 개념보다는 커스텀 필터가 실행되고 인증이 완료되어서
해당 필터가 수행될 때 이미 인증완료된 상태이기에 해당 로직이 수행되지 않고 자연스럽게 통과되는 것이다.
- 어떤 필터인가?
현재 넣어주는 필터는 클라이언트가 요청 시, 헤더에 있는 토큰이 유효한 토큰인지 검사하는 로직이다.
아래의 JwtAuthenticationFilter 에서 설명하겠지만, 이 설정으로 인해 이 필터는 반드시 실행된다는 것을 알자.
이는 로그인, 회원가입의 경우도 마찮가지다. 로그인, 회원가입이 permitAll 했다고 시큐리티 필터를 아예 수행하지 않는 게 아니라는 것을 알아야 한다.
그래서 아래에서 해당 코드를 보면 알겠지만, 헤더에 빈 값이 담겨져 있을 경우
당장 예외를 발생시키지 않고 다음 필터로 넘어가게 코드를 짰다.
그리고 로그인, 회원가입 리소스의 경우, 인증되지 않는 객체여도 허용되게 설정해주었기에 그대로 컨트롤러로 넘어가게 된다.
근데 그 외, 리소스의 경우, 해당 필터를 지나쳤기에 인증되지 않았기 때문에
마지막 필터 단계에서 인증 예외가 발생하게 되는 것이다.
주의) Bean 으로 만든 Filter 를 넣으면 안된다. 반드시, 빈 클래스를 넣어주기!!
2. DTO
1. AccountDTO
- 사용자의 계정 정보
- 회원가입 시, 기입하는 내용 + accountType
(accountType 은 사용자가 입력하지 않고, 서비스 로직에서 직접 넣어줌.
일반용 / 관리자용 회원가입은 별도라 자원 호출에 따라 구분한다.)
package io.csy.hot.model.dto;
import io.csy.hot.model.entity.AccountEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
public class AccountDTO {
private int accountId;
private String accountEmail;
private String accountPassword;
private String accountName;
private String accountBirth;
private String accountAddress;
private String accountPhoneNumber;
private String accountGender;
private String accountType;
// Entity -> DTO 하는 메소드
public static AccountDTO toDTO(AccountEntity account) {
return new AccountDTO(
account.getAccountId(),
account.getAccountEmail(),
account.getAccountPassword(),
account.getAccountName(),
account.getAccountBirth(),
account.getAccountAddress(),
account.getAccountPhoneNumber(),
account.getAccountGender(),
account.getAccountType());
}
}
2. SignUpDTO
- 회원가입 시, 실제 기입하는 내용
- 데이터 유효성 검사 포함되어 있음.
package io.csy.hot.model.dto;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@ToString
public class SignUpDTO {
@NotEmpty(message = "이메일을 입력하지 않았습니다.")
@Email(message = "이메일 형식이 맞지 않습니다.")
private String accountEmail;
@NotEmpty(message = "비밀번호을 입력하지 않았습니다.")
@Pattern(regexp="[a-zA-Z1-9]{6,12}", message = "비밀번호는 영어와 숫자로 포함해서 6~12자리 이내로 입력해주세요.")
private String accountPassword;
@NotBlank(message = "이름을 입력하지 않았습니다.")
@Size(min = 2, max = 8, message = "이름을 2 ~ 8 자 사이로 입력해주세요.")
private String accountName;
@NotNull // null 불가능
private String accountBirth;
@NotNull
private String accountAddress;
@NotNull
@Pattern(regexp = "^01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$", message = "전화번호 형식이 맞지 않습니다.")
private String accountPhoneNumber;
@NotNull
private String accountGender;
}
3. LoginDTO
- 로그인 시, 기입하는 내용
package io.csy.hot.model.dto;
import javax.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Builder
public class LoginDTO {
@NotNull
private String accountEmail;
@NotNull
private String accountPassword;
}
3. Entity
1. AccountEntity
package io.csy.hot.model.entity;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Setter
@Builder
@Table(name="account_privacy")
public class AccountEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "account_id")
private int accountId;
@Column(name = "account_email")
private String accountEmail;
@Column(name = "account_password")
private String accountPassword;
@Column(name = "account_name")
private String accountName;
@Column(name = "account_birth")
private String accountBirth;
@Column(name = "account_address")
private String accountAddress;
@Column(name = "account_phone_number")
private String accountPhoneNumber;
@Column(name = "account_gender")
private String accountGender;
@Column(name = "account_type")
private String accountType;
}
4. Controller
- 회원가입
- 로그인
package io.csy.hot.controller;
import javax.validation.Valid;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.csy.hot.jwt.TokenResponse;
import io.csy.hot.model.AccountService;
import io.csy.hot.model.dto.AccountDTO;
import io.csy.hot.model.dto.LoginDTO;
import io.csy.hot.model.dto.SignUpDTO;
import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
@RequestMapping("auth")
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class AccountController {
private final AccountService accountService;
@PostMapping("/sign-up")
public void signUp(@Valid @RequestBody SignUpDTO signUp) throws Exception {
AccountDTO account = accountService.signUp(signUp);
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginDTO login) throws Exception {
TokenResponse token = accountService.login(login);
return token;
}
}
5. Repository
package io.csy.hot.model.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import io.csy.hot.model.entity.AccountEntity;
@Repository
public interface AccountRepository extends JpaRepository<AccountEntity, Integer> {
boolean existsByAccountEmail(String accountEmail);
Optional<AccountEntity> findAllByAccountEmail(String accountEmail);
}
6. Service
package io.csy.hot.model;
import javax.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import io.csy.hot.exception.CustomException;
import io.csy.hot.exception.UserErrorCode;
import io.csy.hot.jwt.JwtProvider;
import io.csy.hot.jwt.TokenResponse;
import io.csy.hot.model.dto.AccountDTO;
import io.csy.hot.model.dto.LoginDTO;
import io.csy.hot.model.dto.SignUpDTO;
import io.csy.hot.model.entity.AccountEntity;
import io.csy.hot.model.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final AuthenticationManager authenticationManager;
private final JwtProvider jwtProvider;
private final PasswordEncoder passwordEncoder;
@Transactional
public AccountDTO signUp(SignUpDTO signUpRequest) throws Exception {
boolean isExist = accountRepository.existsByAccountEmail(signUpRequest.getAccountEmail());
if (isExist) {
throw new CustomException(UserErrorCode.EXISTED_SIGNUP_EMAIL_PARAMETER);
}
String encodedPassword = passwordEncoder.encode(signUpRequest.getAccountPassword());
AccountEntity account = AccountEntity.builder().accountEmail(signUpRequest.getAccountEmail())
.accountPassword(encodedPassword).accountName(signUpRequest.getAccountName())
.accountBirth(signUpRequest.getAccountBirth()).accountAddress(signUpRequest.getAccountAddress())
.accountPhoneNumber(signUpRequest.getAccountPhoneNumber())
.accountGender(signUpRequest.getAccountGender()).accountType("USER").build();
account = accountRepository.save(account);
return AccountDTO.toDTO(account);
}
@Transactional
public TokenResponse login(LoginDTO login) throws Exception {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(login.getAccountEmail(), login.getAccountPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
TokenResponse token = jwtProvider.createTokenByLogin(authentication);
return token;
}
}
1. 회원가입 : signUp(SignUpDTO signUpRequest)
- 존재하는 이메일인지, 검사한다. 만약, 존재하는 이메일이라면, 예외던짐. (사용자 정의 예외처리한 상태)
- ExceptionHandling 이 된 코드지만, 설명은 하지 않는다.
- 존재한다면, passwordEncoder.encode() 를 통해 패스워드를 인코딩한다. 비밀번호를 그냥 DB 에 넣으면 안됨.
암호화하여 넣어야 한다.
2. 로그인 : login(LoginDTO login)
0. Authentication 인증 객체 란?
앞서, 인증 객체에 대해서 먼저 알아보자.
Authentication 이란, 사용자의 인증 정보를 저장하는 객체이다.
객체 구조?
1. principal User객체를 저장 -> userDetails(AccountDetail)
2. credentials 사용자 비밀번호
3. authorities 인증된 사용자의 권한 목록 -> userDetails.getAuthorities()
4. details 인증 부가 정보
5. Authednticated 인증 여부(Bool)
** 코드 한줄 씩 설명
1. Authentication 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(login.getAccountEmail(), login.getAccountPassword());
- Authorities 은 넣지 않아, Granted Authorities=[]] 로 비어져있는 상태이다.
즉, Authentication 객체를 생성하는데, 아직 인증이 완료 되지 않은 상태
** 인증 완료 전, 객체 예시
UsernamePasswordAuthenticationToken
[Principal=cso6@naver.com,
Credentials=[PROTECTED],
Authenticated=false,
Details=null,
Granted Authorities=[]]
- UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받는다.
AbstractAuthenticationToken은 Authentication을 상속받는다.

2. AuthenticationManager 인증 시작
Authentication authentication = authenticationManager.authenticate(authenticationToken);
- 만든 인증객체를 가지고 인증을 시작한다. AuthenticationManager 가 아이디, 비밀번호 검증을 알아서 하고 성공 시, Authentication 인증 객체를 반환한다.
- 근데 사실, authenticationManager.authenticate 에는 loadUserByUsername 가 실행이 포함되어 있다.
여기서 우리가 만든 계정 정보 accountDetails 라는 객체를 반환하는 등 커스텀마이징하기 위해 해당 메소드를 오버라이딩 할 것이다.
=> UserDetailsService.loadUserByUsername 를 오버라이딩해야 함.
우리가 만든 계정 정보 accountDetails과 오버라이딩 하는 코드는 아래의 AccountDetailsService 부분에서 나온다.
- security 인증 필터 단계를 공부하면 알겠지만
authenticationManager.authenticate 는 AuthenticationProvider에게 위임한다.
- 유효한지 확인 후 인증이 완료된다면,
UserDetailsService.loadUserByUsernam가 return 하는 AccountDetails 객체를 Principal로 담고 있는
Authentication 객체를 return 하게 되는 것이다.
** 인증 완료 후, Authentication 객체 예시
[Principal=io.csy.hot.jwt.AccountDetails [Username=cso6005@naver.com, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_user]],
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[ROLE_USER]]
- 만약, 아이디와 비밀번호 틀린다면,
두 경우 모두, authentication.BadCredentialsException 를 발생시킨다.
(exception.getMessage() : 자격 증명에 실패하였습니다. 라는 메시지이 이다.)
=> 해당 예외에 대해서는 globalExceptionHandling 에서 처리 가능하다. -- 해당 코드는 설명 제외 전체 코드에서 확인 가능.
(인증, 인가 예외 시큐리티필터를 수행 중 발생한 에러가 아닌 컨트롤러 거쳐 서비스 로직 수행 중 발생한 예외이기 때문에)
3. 토큰 생성 시작
- jwt 를 사용하지 않는다면,
이렇게 인증 완료 후 반환 받은 authentication를 session 에 저장하기 위해 securityContextHolder 에 저장하는 게
로그인 시, 인증 작업의 끝 단계이다.
하지만, 우리는 jwt 인증 방식을 사용하여 세션을 사용하지 않을 것이기 때문
로그인 인증 후 securityContextHolder 에 따로 저장하지 않고,
토큰 생성 jwtProvider 실행할 것이다.
토큰 생성 코드는 아래의 jwtProvider 에 나옴.
TokenResponse token = jwtProvider.createTokenByLogin(authentication);
7. AccountDetailsService
로그인 인증 시,
AuthenticationManager 에서 실행되는 loadUserByUsername 를 오버라이딩하여 커스텀 마이징 하는 코드
1. AccountDetails
- 인증 완료 후, 반환되는 authentication 인증 객체의 Principal 에 담은 객체
- User 클래스를 상속받아야 한다. 그리고 User는 UserDetails 를 상속받음.
package io.csy.hot.jwt;
import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import io.csy.hot.model.entity.AccountEntity;
import lombok.Getter;
@Getter
public class AccountDetails extends User{
private final AccountEntity account;
// UserDetails
AccountDetails(AccountEntity account) {
super(account.getAccountEmail(), account.getAccountPassword(), List.of(new SimpleGrantedAuthority("ROLE_" + account.getAccountType())));
this.account = account;
}
}
- 회원 이메일, 회원 비밀번호, 권한 Role 이 들어간다.
- 권한을 넣을 때, "ROLE_" 를 넣는 이유는 아래 링크에서 확인 가능하다.
https://github.com/cso6005/Auth_Project/issues/1
Role 권한 지정 에러 · Issue #1 · cso6005/Auth_Project
문제 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ... // 인가 권한 허용 http .authorizeRequests() .antMatchers("/board/test").hasRole("USER") .antMatchers("/auth/**")....
github.com
2. AccountDetailsService
package io.csy.hot.jwt;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import io.csy.hot.model.entity.AccountEntity;
import io.csy.hot.model.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AccountDetailsService implements UserDetailsService {
private final AccountRepository accountRepository;
@Override
public UserDetails loadUserByUsername(String accountEmail) throws UsernameNotFoundException {
AccountEntity account = accountRepository.findAllByAccountEmail(accountEmail)
.orElseThrow(() -> new UsernameNotFoundException(accountEmail + " 사용자 조회 불가")); //UsernameNotFoundException - RuntimeException
return new AccountDetails(account);
}
}
- 아이디 틀리면 UsernameNotFoundException 예외 던져야 한다.
- 사용자 이메일로 계정을 찾은 후, AccountDetails 객체를 반환시켜준다.
- 설명은, accountService 에서 다 함.
8. 토큰 발행
현재, Refresh 토큰 관련 내용 빠져 있음.
1. Subject
토큰 발행시, Clamis 의 Subject 에 들어갈 내용
이메일과 권한 그리고
atk 과 rtk 를 구분하는 accountType 이 들어감.
package io.csy.hot.jwt;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Subject {
private String accountEmail;
private String accountRole;
private String tokenType;
public static Subject atk(String accountEmail, String accountRole) {
return new Subject(accountEmail, accountRole, "ATK");
}
public static Subject rtk(String accountEmail, String accountRole) {
return new Subject(accountEmail, accountRole, "RTK");
}
}
2. TokenResponse
클라이언트에게 응답할 보낼 토큰 객체
일단, 현 코드는 atk 와 rtk 를 보내는데, 추후 rtk는 Redis 에 저장할 예정. accountType 도 같이 보낼지 고민 중.
package io.csy.hot.jwt;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
@Builder
public class TokenResponse {
private final String atk;
private final String rtk;
private String accountType;
public static TokenResponse tokenResponse(String atk, String rtk) {
return TokenResponse.builder().atk(atk).rtk(rtk).build();
}
}
3. JwtProvider
package io.csy.hot.jwt;
import java.util.Base64;
import java.util.Date;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class JwtProvider {
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${spring.jwt.token.key}")
private String key;
@Value("${spring.jwt.live.atk}")
private Long atkLive;
@Value("${spring.jwt.live.rtk}")
private Long rtkLive;
@Value("${spring.jwt.issuer}")
private String issuer;
@PostConstruct
protected void init() {
key = Base64.getEncoder().encodeToString(key.getBytes());
}
public TokenResponse createTokenByLogin(Authentication authentication) throws JsonProcessingException {
String accountEmail = authentication.getName();
String authorities = authentication.getAuthorities().stream().map(grantedAuthority -> grantedAuthority.getAuthority()).collect(Collectors.joining(","));
Subject atkSubject = Subject.atk(accountEmail, authorities);
String atk = createToken(atkSubject, atkLive);
return new TokenResponse(atk, null, null);
}
private String createToken(Subject subject, Long tokenLive) throws JsonProcessingException {
// 객체 -> Json 문자열
String subjectStr = objectMapper.writeValueAsString(subject);
Claims claims = Jwts.claims().setIssuer(issuer).setSubject(subjectStr);
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + tokenLive);
return Jwts.builder()
.setClaims(claims) //클레임
.setIssuedAt(new Date(now)) // 발급 시간
.setExpiration(accessTokenExpiresIn) // 만료 시간
.signWith(SignatureAlgorithm.HS256, key) // 알고리즘, key
.compact();
}
// atk 로 jwt 의 payload 에 있는 유저 정보 Subject 로 꺼내기 // 계정id, 계정유형, 토큰유형 정보
public Subject getSubject(String atk) throws JsonProcessingException {
String subjectStr = Jwts.parser().setSigningKey(key).parseClaimsJws(atk).getBody().getSubject();
return objectMapper.readValue(subjectStr, Subject.class);
}
}
- SignatureAlgorithm.HS256 알고리즘 사용
- key, atkLive, rtkLive, issuer 경우, properties 파일에서 설정해준다. (보안 이유로)
(키, atk 유효시간, rtk 유효시간, 발급자)
** application.properties 에 아래와 같이 설정해주면 된다.
# token secret key - gitignore
spring.jwt.token.key=
spring.jwt.live.atk=10000000
spring.jwt.live.rtk=1200000000
spring.jwt.issuer=
- key 인코딩 작업 init() 는 @PostConstruct 설정으로 제일 먼저 실행되게 해주기
- 토큰 발급 시, 어떤 내용을 넣을 지 개발자 마음. 알아서 정해주기
- getSubject() : 토큰으로 유저정보 Subject 꺼내는 메소드
9. JwtAuthenticationFilter 토큰 인증 필터 (로그인 후, 클라이언트 요청 시)
- 기본적으로 Filter로 수행되는 것은 UsernamePasswordAuthenticationFilter이다.
UsernamePasswordAuthenticationToken: username, password를 쓰는 form기반 인증을 처리하는 필터.
=> 그러나, 앞서 SecurityCofig 에서 설정한 대로 이 필터에 앞서 해당 커스텀 필터 JwtAuthenticationFilter 를 먼저 실행되게 해주었다.
- OncePerRequestFilter 를 상속받는 이유?
의도치 않은 경우, 매번 Filter의 내용이 수행되는 것을 방지해야 한다.
그래서 모든 서블릿에 일관된 요청을 처리하기 위한 필터. 즉, 사용자의 한번에 요청 당 딱 한번 실행되게 하기 위해서GenericFilterBean을 상속한 OncePerRequestFilter 를 사용한다.
주의. OncePerRequestFilter를 상속하여 구현한 경우, doFilter 가 아님. doFilterInternal 메서드를 오버라이딩
package io.csy.hot.jwt;
import java.io.IOException;
import java.util.Objects;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.JwtException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final AccountDetailsService accountDetailsService;
public JwtAuthenticationFilter(JwtProvider jwtProvider, AccountDetailsService accountDetailsService) {
this.jwtProvider = jwtProvider;
this.accountDetailsService = accountDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
if (StringUtils.hasText(authorization)) {
String atk = authorization.substring(7);
try {
Subject subject = jwtProvider.getSubject(atk);
String requestURI = request.getRequestURI();
if (subject.getTokenType().equals("RTK") && !requestURI.equals("/auth/reissue")) {
throw new JwtException("잘못된 토큰 입니다.");
}
UserDetails userDetails = accountDetailsService.loadUserByUsername(subject.getAccountEmail());
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "",
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication); // 정상 토큰이면 SecurityContext에 저장
} catch (JwtException ex) {
// 예외 처리 해주기
}
}
filterChain.doFilter(request, response);
}
}
** 코드 한줄 씩 설명
1. 헤더에서 토큰 들고오기
String authorization = request.getHeader("Authorization");
if (StringUtils.hasText(authorization)) {
String atk = authorization.substring(7);
// 생략
}
filterChain.doFilter(request, response);
헤더에서 Authorization 를 즉, 토큰을 들고 온다.
해당 토큰이 존재할 경우, 필터 로직을 수행한다. 없을 경우, 해당 필터를 통과한다.
(즉, 로그인, 회원가입 리소스와 또, 일반 리소스라 토큰이 있어야 하는데 없는 경우에 통과할 것이다.)
** StringUtils.hasText()
null이 아니고, 빈문자열도 아니며, 공백으로만 이루어져 있는 문자열도 아닌 문자열인 경우에만 true를 retrun
2. 토큰 들고오기
Subject subject = jwtProvider.getSubject(atk);
if (subject.getTokenType().equals("RTK") && !requestURI.equals("/auth/reissue")) {
throw new JwtException("잘못된 토큰 입니다.");
}
해당 토큰으로 subject 유저 정보를 들고온다.
subject 가 rtk 인데, /auth/reissue 리소스가 아닐 경우,예외 던져준다. (이에 대해서는 리프레시 토큰에서 설명)
3. 인증 객체 생성
loadUserByUsername 으로 UserDetails 유저 객체 조회
AccountDetail: 사용자 이메일, 패스워드, 회원 유형(일반 유저, 관리자) 정보가 담겨져 있음.
해당 객체를 가지고 Authentication 객체를 생성한다.
UserDetails userDetails = accountDetailsService.loadUserByUsername(subject.getAccountEmail());
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
4. Authentication 인증 객체 를 SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication); // 정상 토큰이면 SecurityContext에 저장
filterChain.doFilter(request, response);
최종 인증 결과 즉, Authentication 인증 객체 를 SecurityContext에 저장한다.
그리고 우리는 저장한 인증 객체를 전역적으로 참조 가능하다.
- SecurityContextHolder : SecurityContext 객체를 저장하고 감싸고 있는 wrapper 클래스
- SecurityContext : Authentication 객체를 어디서든 꺼내어 쓸 수 있도록 제공되는 클래스
SecurityContextHolder는 ThreadLocal에 저장되기 때문에 각기 다른 쓰레드별로 다른
SecurityContextHolder 인스턴스를 가지고 있어서 사용자 별로 각기 다른 인증 객체를 가질 수 있다.
(ThreadLocal: Thread마다 할당된 고유 공간(공유X) -> 다른 쓰레드로부터 안전)
10. 예외 처리
시큐리티의 경우, 두가지의 예외가 존재한다. 인증 또는 인가 에 대한 예외
** 로그인 시, 아이디 비밀번호 틀렸을 때 발생하는 예외 BadCredentialsException 와 헷갈리지 않는다. 현재 우리가 짠 로직은 로그인 회원가입에 대해 서비스로직에서 구현하고 있다. 그러므로 해당 예외는 ControllerAdvice 에서 처리 해줌.
** 응답 메시지 객체, 글로벌핸들링 등 예외 처리 관련한 코드는 설명 생략. 아래 깃 링크 전체 코드 참고하기
1. AuthenticationException 인증 에러
- 인증이 되지않은 유저가 요청을 했을때 발생
- 401 HttpStatus.UNAUTHORIZED
- 토큰이 필요한 리소스에서 헤더에 토큰이 없는 경우
비로그인상태(anonymous)로 접속을 시도하면,
처음에는 AccessDeniedException이 발생하나, isAnonymous()에 해당하기 때문에
AuthentiationException으로 전환되고, AuthenticationEntryPoint로 검사가 넘어간다.
- 토큰이 있지만 틀린 토큰일 경우
토큰 필터 단계에서 예외 발생
=> 인증 예외 처리 인터페이스 AuthenticationEntryPoint 에서 처리한다.
이를 상속받는 custom 예외 처리 클래스를 만들 것이다.
2. AccessDeniedException 인가 에러
- 접근할 수 없는 인가 되지 않은 요청 시, 동작한다.
- 403 HttpStatus.FORBIDDEN
=> 인가 예외 처리 인터페이스 AccessDeniedHandler 에서 처리한다.
이를 상속받는 custom 예외 처리 클래스를 만들 것이다.
- 이 둘 모두 런타임 예외이다.
- 시큐리티 예외는 디스패처 서블릿 뒤에서 컨트롤러 메소드 호출 전에 시큐리티 필터 단계에서 발생한다.
그러므로 당연히 ControllerAdvice 에서 포착할 수 없다.
@ExceptionHandler 및 @ControllerAdvice를 통해 전역 수준에서 이러한 예외를 처리하고 싶다면,
HandlerExceptionResolver 를 사용해야 한다.
일단, 나는 두 예외에 대해서는 글로벌 핸들링에서 작성하지 않고
AuthenticationEntryPoint 과 AccessDeniedHandler 를 오버라이딩하여 내가 원하는 응답 메시지를 커스텀 마이징 하는데에만 그친다.
만약, globalExceptionHandler 에 모두 모으고 싶다면 아래 링크 참조하기
https://www.baeldung.com/spring-security-exceptionhandler
Handle Spring Security Exceptions With @ExceptionHandler | Baeldung
Learn to handle Spring Security Exceptions with @ExceptionHandler.
www.baeldung.com
1. CustomAuthenticationEntryPoint
package io.csy.hot.config;
import java.io.IOException;
import java.util.Date;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.csy.hot.exception.CommonErrorCode;
import io.csy.hot.exception.ErrorResponse;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import springfox.documentation.service.ResponseMessage;
@Getter
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
CommonErrorCode errorCode = CommonErrorCode.AUTH_ERROR;
String timestamp = new Date().toString();
response.setStatus(errorCode.getHttpStatus().value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ErrorResponse message = new ErrorResponse(timestamp, errorCode.getHttpStatus().value(), errorCode.name(), errorCode.getMessage(), request.getRequestURI());
String res = this.convertObjectToJson(message);
response.getWriter().print(res);
}
private String convertObjectToJson(Object object) throws JsonProcessingException {
return object == null ? null : objectMapper.writeValueAsString(object);
}
}
2. CustomAccessDeniedHandler
package io.csy.hot.config;
import java.io.IOException;
import java.util.Date;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.csy.hot.exception.CommonErrorCode;
import io.csy.hot.exception.ErrorResponse;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException, ServletException {
CommonErrorCode errorCode = CommonErrorCode.ACCESS_DENIED;
String timestamp = new Date().toString();
response.setStatus(errorCode.getHttpStatus().value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ErrorResponse message = new ErrorResponse(timestamp, errorCode.getHttpStatus().value(), errorCode.name(), errorCode.getMessage(), request.getRequestURI());
String res = this.convertObjectToJson(message);
response.getWriter().print(res);
}
private String convertObjectToJson(Object object) throws JsonProcessingException {
return object == null ? null : objectMapper.writeValueAsString(object);
}
}
'SW_STUDY > SpringBoot' 카테고리의 다른 글
[Spring] @RequestBody, @RequestParam, @ModelAttribute (0) | 2023.05.23 |
---|---|
[Spring boot] @PostConstruct 어노테이션 (0) | 2023.03.08 |
[Spring boot] @Transactional 어노테이션 (0) | 2023.03.08 |
[Spring Boot, Java] Object ↔ JSON 문자열 : Gson (0) | 2023.03.08 |
[Spring Boot, Java] Entity ↔ DTO 변환 : 자바 코드 매핑 & ModelMapper (0) | 2023.03.08 |