Json Web Token을 이용한 회원 가입 및 로그인 기능 구현
주로 검증 과정에 대해 포스팅하였습니다.


| 필드명 | 타입 | 설명 |
|---|---|---|
| id | Long | pk |
| String | 이메일 주소 | |
| username | String | 사용자 이름 |
| password | String | 비밀번호 |
| roles | String | 회원 등급 정보 |
| created_at | LocalDateTime | 생성 일시 |
| updated_at | LocalDateTime | 수정 일시 |
.
└── com
└── apipractice
├── ApiPracticeApplication.java
├── controller
│ └── user
│ ├── UserController.java
│ ├── request
│ │ └── SignUpRequest.java
│ └── response
│ └── SignUpResponse.java
├── dto
│ └── user
│ └── UserDto.java
├── entity
│ └── user
│ └── User.java
├── enums
│ └── UserRole.java
├── repository
│ └── user
│ └── UserRepository.java
└── service
└── user
└── UserService.javaentity, enum, dto, repository, service, controller, request, response를 만들어서 요청을 하면 회원가입이 완료된다.
기존의 롬복 설정에서 시큐리티가 적용되어 있어서 호출이 안 되는 문제가 발생했는데 아래의 설정을 추가해서 해결하였다.
@SpringBootApplication(exclude={SecurityAutoConfiguration.class})
.
└── com
└── apipractice
├── controller
│ ├── response
│ │ └── Response.java
├── exception
│ ├── ApiPracticeApplicationException.java
│ ├── CustomAuthenticationEntryPoint.java
│ ├── ErrorCode.java
│ └── GlobalControllerAdvice.java
├── repository
│ └── user
│ └── UserRepository.java
└── service
└── user
└── UserService.java
{ // 회원가입시 중복된 요청을 보내면 아래와 같음.
"resultCode": "DUPLICATED_EMAIL",
"result": null
}
package com.jejeong.apipractice.entity.common;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import java.sql.Timestamp;
import java.time.Instant;
import lombok.Getter;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class EntityDate {
@Column(name = "created_at")
private Timestamp createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private Timestamp updatedAt;
@Column(name = "removed_at")
private Timestamp removedAt;
@PrePersist
void createAt() {
this.createdAt = Timestamp.from(Instant.now());
}
@PreUpdate
void updatedAt() {
this.updatedAt = Timestamp.from(Instant.now());
}
}
public class User extends EntityDate {

-> 가입한 날짜가 기록된다.
// SecurityConfiguration.java
package com.jejeong.apipractice.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class SecurityConfiguration {
@Bean
public BCryptPasswordEncoder encoderPassword() {
return new BCryptPasswordEncoder();
}
}
// UserService.java
public UserDto join(SignUpRequest req) {
userRepository.findByEmail(req.getEmail()).ifPresent((ip) -> {
throw new ApiPracticeApplicationException(
ErrorCode.DUPLICATED_EMAIL, String.format("email is %s", req.getEmail()));
});
String password = passwordEncoder.encode(req.getPassword());
User user = userRepository.save(User.of(req.getEmail(), password, req.getUsername()));
return UserDto.fromEntity(user);
}
public SignInResponse signIn(SignInRequest req) {
UserDto userDto = loadUserByEmail(req.getEmail());
if (!passwordEncoder.matches(req.getPassword(), userDto.getPassword())) {
throw new ApiPracticeApplicationException(ErrorCode.INVALID_PASSWORD);
}
String accessToken = jwtTokenUtils.generateAccessToken(userDto.getEmail());
String refreshToken = jwtTokenUtils.generateRefreshToken(userDto.getEmail());
return new SignInResponse(userDto.getEmail(), accessToken, refreshToken);
}
2) 실제 토큰을 발급하는 부분은 유틸성 클래스를 따로 만들어주었다.
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
@Component
public class JwtTokenUtils {
@Value("${jwt.token.secret-key.access}")
private String accessSecretKey;
@Value("${jwt.token.secret-key.refresh}")
private String refreshSecretKey;
@Value("${jwt.token.expired-time-ms.access}")
private Long accessExpiredTimeMs;
@Value("${jwt.token.expired-time-ms.refresh}")
private Long refreshExpiredTimeMs;
public String generateAccessToken(String username) {
return doGenerateToken(username, accessSecretKey, accessExpiredTimeMs);
}
public String generateRefreshToken(String username) {
return doGenerateToken(username, accessSecretKey, refreshExpiredTimeMs);
}
private String doGenerateToken(String username, String key, long expiredTimeMs) {
Claims claims = Jwts.claims();
claims.put("username", username);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs))
.signWith(SignatureAlgorithm.HS256, getSigningKey(key))
.compact();
}
public Boolean validate(UserDetails userDetails, String token, String key) {
String username = getUsername(token, key);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token, key);
}
public String getUsername(String token, String key) {
return extractAllClaims(token, key).get("username", String.class);
}
public Claims extractAllClaims(String token, String key) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(key))
.build()
.parseClaimsJws(token)
.getBody();
}
private Key getSigningKey(String secretKey) {
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public Boolean isTokenExpired(String token, String key) {
Date expiration = extractAllClaims(token, key).getExpiration();
return expiration.before(new Date());
}
여기까지 하면, 로그인하고나서 토큰 생성까지 완료된다.
{
"resultCode": "SUCCESS",
"result": {
"email": "java@gmail.com",
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImphdmFAZ21haWwuY29tIiwiaWF0IjoxNzIyMjIxMTQ0LCJleHAiOjE3MjIyNDcwNjR9.k4FAmQidv38WfRSe8zricbng8jMPVIY2SuCUvCl_gB0",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImphdmFAZ21haWwuY29tIiwiaWF0IjoxNzIyMjIxMTQ0LCJleHAiOjE3MjQ4MTMxNDR9.hdHXVQVyGzZxAlz3hjtpv-mxS3eczTwfv2GuzKXNJYk"
}
}
https://github.com/jaeeunjeong/auth-platform
관련된 부분은 아래 링크를 확인하면 된다.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public DefaultSecurityFilterChain configure(HttpSecurity http) throws Exception {
http.httpBasic(AbstractHttpConfigurer::disable);
http.formLogin(AbstractHttpConfigurer::disable);
http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement(
manager -> manager.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.authorizeHttpRequests(request -> request.requestMatchers("/**").permitAll());
return http.build();
}
}
spring 3.2.0 이후에 왜 그런진 못 찾았지만, 생성자 바인딩시 기본 생성자로 바인딩해줘야하는데
롬복 어노테이션이 되질 않아서 수동으로 만들어줬다.
프로젝트 뼈대를 하나하나 만들어야하고, 테스트 코드를 하나하나 진행해가면서 해가다보니 시간이 생각보다 많이 걸렸다.
게다가 최신 스프링부트 버전이나 시큐리티를 사용하다보니 기존에 참고할 자료가 deprecate된게 많아서 찾는데 어려움을 겪었다.
사실 커밋이나 블로그에 포스팅하기까지 토큰을 이용한 처리를 완료해서 올리고 싶었는데 일단 회원가입/ 로그인 기능 구현을 먼저 포스팅을 하기로 변경하였다...