Security와 Jwt Token / Refresh Token으로 회원가입과 로그인을 Api가 있습니다. 로그인 성공시 Access 토큰과 Refresh 토큰이 발급되고 Access 토근이 만료되면 Refresh 토큰의 값으로 Access 토큰과 Refresh 토큰을 재발급 합니다.
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'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:db
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.properties.hibernate.format_sql=true
jwt.secret.key=7J2064W467Kg7J207IWY7Lqg7ZSE7IiY6rCV7IOd67aE65Ok7ZmU7J207YyF7ZWY7IS47JqU7KKL7J2A7ZqM7IKs7JeQ66qo65GQ7Leo7JeF7ISx6rO17ZWY7Iuk6rGw652866+/7Iq164uI64uk65287J2067iM7IS47IWY65Ok7Ja07KO87IWU7ISc6rCQ7IKs7ZWp64uI64uk64+E7JuA7J2065CY7JeI7Jy866m07KKL6rKg7Iq164uI64uk
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer ignoringCustomizer() {
return (web) -> web.ignoring().antMatchers("/h2-console/**");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors();
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests().antMatchers("/api/account/**").permitAll()
.anyRequest().authenticated()
.and().addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Getter
@Entity
@NoArgsConstructor
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long accountId;
@NotBlank
private String nickname;
@NotBlank
private String pw;
public Account(AccountReqDto accountReqDto) {
this.nickname = accountReqDto.getNickname();
this.pw = accountReqDto.getPw();
}
}
@Getter
@NoArgsConstructor
public class AccountReqDto {
@NotBlank
private String nickname;
@NotBlank
private String pw;
private String pwck;
public AccountReqDto(String nickname, String pw, String pwck) {
this.nickname = nickname;
this.pw = pw;
this.pwck = pwck;
}
public void setEncodePwd(String encodePwd) {
this.pw = encodePwd;
}
}
@Getter
@Entity
@NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String refreshToken;
@NotBlank
private String accountEmail;
public RefreshToken(String token, String email) {
this.refreshToken = token;
this.accountEmail = email;
}
public RefreshToken updateToken(String token) {
this.refreshToken = token;
return this;
}
}
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByEmail(String email);
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByAccountEmail(String email);
}
@Service
@RequiredArgsConstructor
public class AccountService {
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
private final AccountRepository accountRepository;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public GlobalResDto signup(AccountReqDto accountReqDto) {
// nickname 중복검사
if(accountRepository.findByEmail(accountReqDto.getNickname()).isPresent()){
throw new RuntimeException("Overlap Check");
}
// 패스워드 암호화
accountReqDto.setEncodePwd(passwordEncoder.encode(accountReqDto.getPassword()));
Account account = new Account(accountReqDto);
// 회원가입 성공
accountRepository.save(account);
return new GlobalResDto("Success signup", HttpStatus.OK.value());
}
@Transactional
public GlobalResDto login(LoginReqDto loginReqDto, HttpServletResponse response) {
// 아이디 검사
Account account = accountRepository.findByEmail(loginReqDto.getNickname()).orElseThrow(
() -> new RuntimeException("Not found Account")
);
// 비밀번호 검사
if(!passwordEncoder.matches(loginReqDto.getPassword(), account.getPassword())) {
throw new RuntimeException("Not matches Password");
}
// 아이디 정보로 Token생성
TokenDto tokenDto = jwtUtil.createAllToken(loginReqDto.getNickname());
// Refresh토큰 있는지 확인
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByAccountEmail(loginReqDto.getEmail());
// 있다면 새토큰 발급후 업데이트
// 없다면 새로 만들고 디비 저장
if(refreshToken.isPresent()) {
refreshTokenRepository.save(refreshToken.get().updateToken(tokenDto.getRefreshToken()));
}else {
RefreshToken newToken = new RefreshToken(tokenDto.getRefreshToken(), loginReqDto.getEmail());
refreshTokenRepository.save(newToken);
}
// response 헤더에 Access Token / Refresh Token 넣음
setHeader(response, tokenDto);
return new GlobalResDto("Success Login", HttpStatus.OK.value());
}
private void setHeader(HttpServletResponse response, TokenDto tokenDto) {
response.addHeader(JwtUtil.ACCESS_TOKEN, tokenDto.getAccessToken());
response.addHeader(JwtUtil.REFRESH_TOKEN, tokenDto.getRefreshToken());
}
}
@Getter
@NoArgsConstructor
public class GlobalResDto {
private String msg;
private int statusCode;
public GlobalResDto(String msg, int statusCode) {
this.msg = msg;
this.statusCode = statusCode;
}
}
@Getter
@NoArgsConstructor
public class TokenDto {
private String accessToken;
private String refreshToken;
public TokenDto(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final UserDetailsServiceImpl userDetailsService;
private final RefreshTokenRepository refreshTokenRepository;
// private static final long ACCESS_TIME = 30 * 60 * 1000L;
// private static final long REFRESH_TIME = 7 * 24 * 60 * 60 * 1000L;
private static final long ACCESS_TIME = 60 * 1000L;
private static final long REFRESH_TIME = 2 * 60 * 1000L;
public static final String ACCESS_TOKEN = "Access_Token";
public static final String REFRESH_TOKEN = "Refresh_Token";
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// bean으로 등록 되면서 딱 한번 실행이 됩니다.
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// header 토큰을 가져오는 기능
public String getHeaderToken(HttpServletRequest request, String type) {
return type.equals("Access") ? request.getHeader(ACCESS_TOKEN) :request.getHeader(REFRESH_TOKEN);
}
// 토큰 생성
public TokenDto createAllToken(String nickname) {
return new TokenDto(createToken(nickname, "Access"), createToken(nickname, "Refresh"));
}
public String createToken(String nickname, String type) {
Date date = new Date();
long time = type.equals("Access") ? ACCESS_TIME : REFRESH_TIME;
return Jwts.builder()
.setSubject(nickname)
.setExpiration(new Date(date.getTime() + time))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
// 토큰 검증
public Boolean tokenValidation(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (Exception ex) {
log.error(ex.getMessage());
return false;
}
}
// refreshToken 토큰 검증
// db에 저장되어 있는 token과 비교
// db에 저장한다는 것이 jwt token을 사용한다는 강점을 상쇄시킨다.
// db 보다는 redis를 사용하는 것이 더욱 좋다. (in-memory db기 때문에 조회속도가 빠르고 주기적으로 삭제하는 기능이 기본적으로 존재합니다.)
public Boolean refreshTokenValidation(String token) {
// 1차 토큰 검증
if(!tokenValidation(token)) return false;
// DB에 저장한 토큰 비교
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByAccountNickname(getEmailFromToken(token));
return refreshToken.isPresent() && token.equals(refreshToken.get().getRefreshToken());
}
// 인증 객체 생성
public Authentication createAuthentication(String email) {
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// spring security 내에서 가지고 있는 객체입니다. (UsernamePasswordAuthenticationToken)
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 email 가져오는 기능
public String getEmailFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
}
// 어세스 토큰 헤더 설정
public void setHeaderAccessToken(HttpServletResponse response, String accessToken) {
response.setHeader("Access_Token", accessToken);
}
// 리프레시 토큰 헤더 설정
public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) {
response.setHeader("Refresh_Token", refreshToken);
}
}
@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
// HTTP 요청이 오면 WAS(tomcat)가 HttpServletRequest, HttpServletResponse 객체를 만들어 줍니다.
// 만든 인자 값을 받아옵니다.
// 요청이 들어오면 diFilterInternal 이 딱 한번 실행된다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// WebSecurityConfig 에서 보았던 UsernamePasswordAuthenticationFilter 보다 먼저 동작을 하게 됩니다.
// Access / Refresh 헤더에서 토큰을 가져옴.
String accessToken = jwtUtil.getHeaderToken(request, "Access");
String refreshToken = jwtUtil.getHeaderToken(request, "Refresh");
if(accessToken != null) {
// 어세스 토큰값이 유효하다면 setAuthentication를 통해
// security context에 인증 정보저장
if(jwtUtil.tokenValidation(accessToken)){
setAuthentication(jwtUtil.getEmailFromToken(accessToken));
}
// 어세스 토큰이 만료된 상황 && 리프레시 토큰 또한 존재하는 상황
else if (refreshToken != null) {
// 리프레시 토큰 검증 && 리프레시 토큰 DB에서 토큰 존재유무 확인
boolean isRefreshToken = jwtUtil.refreshTokenValidation(refreshToken);
// 리프레시 토큰이 유효하고 리프레시 토큰이 DB와 비교했을때 똑같다면
if (isRefreshToken) {
// 리프레시 토큰으로 아이디 정보 가져오기
String loginId = jwtUtil.getEmailFromToken(refreshToken);
// 새로운 어세스 토큰 발급
String newAccessToken = jwtUtil.createToken(loginId, "Access");
// 헤더에 어세스 토큰 추가
jwtUtil.setHeaderAccessToken(response, newAccessToken);
// Security context에 인증 정보 넣기
setAuthentication(jwtUtil.getEmailFromToken(newAccessToken));
}
// 리프레시 토큰이 만료 || 리프레시 토큰이 DB와 비교했을때 똑같지 않다면
else {
jwtExceptionHandler(response, "RefreshToken Expired", HttpStatus.BAD_REQUEST);
return;
}
}
}
filterChain.doFilter(request,response);
}
// SecurityContext 에 Authentication 객체를 저장합니다.
public void setAuthentication(String email) {
Authentication authentication = jwtUtil.createAuthentication(email);
// security가 만들어주는 securityContextHolder 그 안에 authentication을 넣어줍니다.
// security가 securitycontextholder에서 인증 객체를 확인하는데
// jwtAuthfilter에서 authentication을 넣어주면 UsernamePasswordAuthenticationFilter 내부에서 인증이 된 것을 확인하고 추가적인 작업을 진행하지 않습니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// Jwt 예외처리
public void jwtExceptionHandler(HttpServletResponse response, String msg, HttpStatus status) {
response.setStatus(status.value());
response.setContentType("application/json");
try {
String json = new ObjectMapper().writeValueAsString(new GlobalResDto(msg, status.value()));
response.getWriter().write(json);
} catch (Exception e) {
log.error(e.getMessage());
}
}
}
public class UserDetailsImpl implements UserDetails {
private Account account;
public Account getAccount() {
return this.account;
}
public void setAccount(Account account) {
this.account = account;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
@Service
@RequiredArgsConstructor
// userDetailsImple에 account를 넣어주는 서비스입니다.
public class UserDetailsServiceImpl implements UserDetailsService {
private final AccountRepository accountRepository;
@Override
public UserDetails loadUserByUsername(String nickname) throws UsernameNotFoundException {
Account account = accountRepository.findByNickname(nickname).orElseThrow(
() -> new RuntimeException("Not Found Account")
);
UserDetailsImpl userDetails = new UserDetailsImpl();
userDetails.setAccount(account);
return userDetails;
}
}
원하던 글입니다!b