내가 계획한 프로젝트명은 재능교환소
이다.
사람과 사람이 모일 수 있는 커뮤니티 게시판에서 나의 재능을 타인의 재능과 교환한다는 뜻이다.
전 프로젝트를 떠올려보면 서버사이드로 구현하여 백엔드와 더불어 프론트엔드 부분까지 만들었었다. 하지만 분리되어 개발되지 않은 탓에 팀원과 일부 부분들이 겹치고, 한 곳에 집중하지 못한다는 느낌을 받았다. 결국 한정적으로 개발된다는 점이 아쉬웠다.
그래서 이번엔 Restful API
형식으로 Spring Boot를 이용하여 모든 백엔드 부분을 개발하고, 다른 한 명의 팀원은 React를 사용하여 프론트엔드를 개발하고 있다.
Spring Boot와 Spring MVC를 활용해 API를 만드는 것부터 시작해, 데이터베이스와의 연동, 사용자 인증 및 권한 부여 등의 기능을 구현하고 있다. 특히 Swagger
를 활용하여 API 문서를 자동으로 생성하고 관리하고 있어 협업 과정이 원할하게 진행되고 있다.
실제 Restful Api를 만들기 전에기능 정의서
,ERD
,API 설계서
를 작성하였다.
자세한 api 설계서는 구글 스프레드시트에 기록되어 있다.
지금까지 만든 패키지 구조는 다음과 같다.
+---main
| +---java
| | \---place
| | \---skillexchange
| | \---backend
| | | BackendApplication.java
| | | WebRestController.java
| | |
| | +---auth
| | | +---config
| | | | ApplicationConfig.java
| | | | SecurityConfig.java
| | | |
| | | +---dto
| | | | OAuth2CustomUser.java
| | | | OAuthAttributes.java
| | | |
| | | \---services
| | | AuthFilterService.java
| | | AuthService.java
| | | AuthServiceImpl.java
| | | JwtService.java
| | | OAuth2AuthenticationSuccessHandler.java
| | | UserOAuth2Service.java
| | |
| | +---chat
| | | +---config
| | | | RabbitConfig.java
| | | | StompEventListener.java
| | | | WebSocketConfig.java
| | | |
| | | +---controller
| | | | ChatMessageController.java
| | | | ChatRoomController.java
| | | |
| | | +---dto
| | | | ChatDto.java
| | | |
| | | +---entity
| | | | ChatMessage.java
| | | | ChatRoom.java
| | | |
| | | +---repository
| | | | ChatMessageRepository.java
| | | | ChatRoomRepository.java
| | | |
| | | \---service
| | | ChatMessageService.java
| | | ChatMessageServiceImpl.java
| | | ChatRoomService.java
| | | ChatRoomServiceImpl.java
| | |
| | +---comment
| | | +---controller
| | | | CommentController.java
| | | |
| | | +---dto
| | | | CommentDto.java
| | | |
| | | +---entity
| | | | Comment.java
| | | | CreatedDateEntity.java
| | | | DeleteStatus.java
| | | |
| | | +---repository
| | | | CommentRepository.java
| | | | CommentRepositoryImpl.java
| | | | CustomCommentRepository.java
| | | |
| | | \---service
| | | CommentSerivce.java
| | | CommentServiceImpl.java
| | |
| | +---common
| | | +---annotation
| | | | ApiErrorCodeExample.java
| | | | ExplainError.java
| | | |
| | | +---config
| | | | ExampleHolder.java
| | | | QuerydslConfiguration.java
| | | | RedisConfig.java
| | | | S3Config.java
| | | | SwaggerConfig.java
| | | |
| | | +---consts
| | | | ConstFields.java
| | | |
| | | +---dto
| | | | ErrorReason.java
| | | | ErrorResponse.java
| | | | ValidationException.java
| | | |
| | | +---entity
| | | | BaseEntity.java
| | | |
| | | +---service
| | | | MailService.java
| | | | RedisService.java
| | | |
| | | \---util
| | | CookieUtil.java
| | | DayOfWeekUtil.java
| | | DummyDataGenerator.java
| | | NestedConvertHelper.java
| | | PasswordGeneratorUtil.java
| | | RedisUtil.java
| | | SecurityUtil.java
| | |
| | +---exception
| | | | AllCodeException.java
| | | | BaseErrorCode.java
| | | | GlobalErrorCode.java
| | | | GlobalExceptionHandler.java
| | | |
| | | +---board
| | | | BoardAleadyRequestSkillException.java
| | | | BoardAleadyScrappedException.java
| | | | BoardErrorCode.java
| | | | BoardInvalidfRequestSkillException.java
| | | | BoardNotFoundException.java
| | | | BoardNumNotFoundException.java
| | | | BoardSelfRequestSkillException.java
| | | | CannotConvertNestedStructureException.java
| | | | CommentNotFoundException.java
| | | | PlaceNotFoundException.java
| | | | SubjectCategoryBadRequestException.java
| | | | SubjectCategoryNotFoundException.java
| | | |
| | | +---chat
| | | | ChatErrorCode.java
| | | | ChatRoomAccessDeniedException.java
| | | | ChatRoomNotFoundException.java
| | | |
| | | \---user
| | | AccessTokenRequiredException.java
| | | AccountLoginRequriedException.java
| | | CustomAccessDeniedHandler.java
| | | CustomAuthenticationEntryPoint.java
| | | RefreshTokenExpiredException.java
| | | RefreshTokenNotFoundException.java
| | | ScrapNotFoundException.java
| | | SocialLoginRequriedException.java
| | | UserEmailNotFoundException.java
| | | UserErrorCode.java
| | | UserIdLoginException.java
| | | UserNotFoundException.java
| | | UserTokenExpriedException.java
| | | WriterAndLoggedInUserMismatchExceptionAll.java
| | |
| | +---file
| | | +---dto
| | | | FileDto.java
| | | |
| | | +---entity
| | | | File.java
| | | |
| | | +---files
| | | | S3Uploader.java
| | | | UploadFile.java
| | | |
| | | +---repository
| | | | FileRepository.java
| | | |
| | | \---service
| | | FileService.java
| | | FileServiceImpl.java
| | |
| | +---notice
| | | +---controller
| | | | NoticeController.java
| | | |
| | | +---dto
| | | | NoticeDto.java
| | | |
| | | +---entity
| | | | Notice.java
| | | |
| | | +---repository
| | | | CustomNoticeRepository.java
| | | | CustomNoticeRepositoryImpl.java
| | | | NoticeRepository.java
| | | |
| | | \---service
| | | NoticeService.java
| | | NoticeServiceImpl.java
| | |
| | +---talent
| | | +---controller
| | | | PlaceController.java
| | | | SubjectCategoryController.java
| | | | TalentController.java
| | | |
| | | +---dto
| | | | PlaceDto.java
| | | | RequestSkillInfo.java
| | | | SubjectCategoryDto.java
| | | | TalentDto.java
| | | |
| | | +---entity
| | | | DayOfWeek.java
| | | | ExchangeStatus.java
| | | | GenderForTalent.java
| | | | Place.java
| | | | SubjectCategory.java
| | | | Talent.java
| | | | TalentScrap.java
| | | | TalentScrapId.java
| | | |
| | | +---repository
| | | | CustomTalentRepository.java
| | | | CustomTalentRepositoryImpl.java
| | | | PlaceRepository.java
| | | | SubjectCategoryRepository.java
| | | | TalentRepository.java
| | | | TalentScrapRepository.java
| | | |
| | | \---service
| | | PlaceService.java
| | | SubjectCategoryService.java
| | | SubjectCategoryServiceImpl.java
| | | TalentService.java
| | | TalentServiceImpl.java
| | |
| | \---user
| | +---controller
| | | UserController.java
| | |
| | +---dto
| | | UserDto.java
| | |
| | +---entity
| | | Authority.java
| | | AuthProvider.java
| | | Gender.java
| | | Refresh.java
| | | User.java
| | |
| | +---repository
| | | RefreshRepository.java
| | | UserRepository.java
| | |
| | \---service
| | UserService.java
| | UserServiceImpl.java
| |
| \---resources
| | application.yml
| | errors.properties
| |
| \---templates
| email_activation.html
| email_findId.html
| email_findPw.html
Spring Seucirty 사용할때 만든 설정파일들을 확인해보자.
Spring Security
와 관련된 파일은 auth
패키지 안에 있다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
/**
* (1) 계정 활성화를 위한 jwt 토큰 : 회원가입(/v1/user/signUp) 후 발급하여 이메일로 보내야 함 (Filtering(X), Controller-Service(O))
* (2) accessToken : 로그인 시 생성하여 헤더에 저장 (Filtering(X), Controller-Service(O))
* (3) refreshToken : 로그인 시 생성하여 DB에 저장 & 쿠키에 저장 (Filtering(X), Controller-Service(O))
* (2)번과 (3)번은 같은 엔드포인트(/v1/user/signIn) 즉, 동일 메서드에 정의
* (4) 회원 정보를 필요로 하는 엔드포인트들을 위해 accessToken 및 refreshToken 검증은 Filtering(O)
*/
private final AuthFilterService authFilterService;
private final AuthenticationProvider authenticationProvider;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final UserOAuth2Service userOAuth2Service;
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
//CorsConfigurationSource 인터페이스를 구현하는 익명 클래스 생성하여 getCorsConfiguration() 메소드 재정의
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
//DaoAuthenticationProvider의 세부 내역을 AuthenticationProvider 빈을 만들어 정의했으므로 인증을 구성해줘야 한다.
.authenticationProvider(authenticationProvider)
//.addFilterAfter(csrfCookieFilterService, BasicAuthenticationFilter.class)
.addFilterBefore(authFilterService, UsernamePasswordAuthenticationFilter.class)
//authFilterService가 인증 전에 실행되어 항상 검증되기 때문에 requestMatchers의 authenticated()과 permitAll()은 영향 X
//하지만 코드 가독성을 위해 requestMatchers를 사용해 명시해주자
.exceptionHandling(configurer -> configurer
.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint)
)
.authorizeHttpRequests((requests) -> requests
.requestMatchers(HttpMethod.PATCH, "/v1/notices/{noticeId}").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/v1/notices/{noticeId}").hasRole("ADMIN")
.requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN")
.requestMatchers("/v1/notices/register").hasRole("ADMIN")
.requestMatchers("/v1/user/**", "/v1/file/**", "/v1/notices/{noticeId}", "/v1/comment/**", "/v1/subjectCategory/**", "/v1/place/**", "/v1/talent/**","/v1/profile/get","/profile", "/actuator/health","/health","/v1/chatRoom/**","/chat/inbox/**").permitAll())
.formLogin(Customizer.withDefaults())
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2AuthenticationSuccessHandler)
.userInfoEndpoint(userInfoEndpointConfig
-> userInfoEndpointConfig.userService(userOAuth2Service)))
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://apic.app")); // Add https://apic.app here
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "PATCH"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setExposedHeaders(Arrays.asList("Authorization"));
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@Configuration
public class ApplicationConfig {
private final UserRepository userRepository;
public ApplicationConfig(UserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* 사용자 정보 반환 : loadUserByUsername() 와 동일 역할
* */
@Bean
public UserDetailsService userDetailsService() {
return id -> userRepository.findById(id)
.orElseThrow(() -> UserNotFoundException.EXCEPTION);
}
/**
* 인증 공급자인 DaoAuthenticationProvider에 세부내역 설정
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
//UserDetailsService는 user DAO를 사용되므로 DaoAuthenticationProvider에 내가 정의한 userDetailsService를 주입
authenticationProvider.setUserDetailsService(userDetailsService());
//BCryptPasswordEncoder로 설정하겠다고 PasswordEncoder 빈을 만든 매서드 passwordEncoder()를 주입
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
/**
* 자동으로 구성되지 않을 때를 대비하기 위해 AuthenticationManager를 명시적으로 정의
* */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
주요 메서드들을 정리해보면 다음과 같다.
먼저 React와 통신하기 위해 CORS
를 설정해주었다.
- Allowed Origins: 허용할 출처(도메인)를 설정한다.
- Allowed Methods: 허용할 HTTP 메소드를 설정한다.
- Allowed Headers: 허용할 헤더를 설정한다.
- Allow Credentials: 인증 정보 허용 여부를 설정한다.
- Max Age: CORS 설정 캐시로 사용할 시간을 설정한다.
/**
* 인증 공급자인 DaoAuthenticationProvider에 세부내역 설정
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
//UserDetailsService는 user DAO를 사용되므로 DaoAuthenticationProvider에 내가 정의한 userDetailsService를 주입
authenticationProvider.setUserDetailsService(userDetailsService());
//BCryptPasswordEncoder로 설정하겠다고 PasswordEncoder 빈을 만든 매서드 passwordEncoder()를 주입
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
ApplicationConfig 파일
에서 DaoAuthenticationProvider
에 세부 내역을 설정하였다. User 엔티티
에서 UserDetails
을 구현하였고, 구현한 UserDetails
의 사용자 정보를 반환하기 위해 userDetailsService()
메서드 빈을 생성하였다.
이는 UserDetailsService
의 loadUserByUsername()
와 동일 역할을 한다. DaoAuthenticationProvider
에 내가 정의한 userDetailsService
를 주입한 것이다.
또한 DaoAuthenticationProvider
에 BCryptPasswordEncoder
로 설정하겠다고 PasswordEncoder
빈을 만든 매서드 passwordEncoder()
를 주입하였다.
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthFilterService extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
private final RefreshRepository refreshRepository;
private final RedisUtil redisUtil;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
//Authorization 이름을 가진 헤더의 값을 꺼내옴
final String authHeader = request.getHeader("Authorization");
String jwt;
//authHeader가 null이고, Bearer로 시작하지 않다면 체인 내의 다음 필터를 호출
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
return;
}
// authHeader의 `Bearer `를 제외한 문자열 jwt에 담음
jwt = authHeader.substring(7);
if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
String blackListValue = (String) redisUtil.getValues(jwt);
//accessToken이 블랙리스트에 등록되었는지 확인
if (blackListValue != null && blackListValue.equals("logout")) {
// 블랙리스트에 등록된 토큰인 경우 예외 처리
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User has been logged out");
return;
}
//accessToken이 만료되었다면
if ( jwtService.isTokenExpired(jwt)) {
//쿠키의 refreshToken과 db에 저장된 refreshToken의 만료일을 확인하고 accessToken 재발급 / 만료되면 재로그인 exception
handleExpiredToken(request, response);
} else {
//accessToken이 만료되지 않았다면 인증정보 등록
authenticateUser(jwt, request, response);
}
}
//체인 내의 다음 필터를 호출
filterChain.doFilter(request, response);
}
private void handleExpiredToken(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String refreshToken = extractRefreshTokenFromCookie(request);
if (refreshToken != null) {
Refresh refresh = refreshRepository.findByRefreshToken(refreshToken);
if (refresh != null) {
User user = userRepository.findWithAuthoritiesById(refresh.getUserId()).orElseThrow(() -> UserNotFoundException.EXCEPTION);
String accessToken = jwtService.generateAccessToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
user.getId(),
"",
true,
true,
true,
true,
user.getAuthorities()
);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
private String extractRefreshTokenFromCookie(HttpServletRequest request) {
// 쿠키에서 refreshToken 가져오기
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private void authenticateUser(String jwt, HttpServletRequest request, HttpServletResponse response) {
UserDetails userDetails = new org.springframework.security.core.userdetails.User(
jwtService.extractUsername(jwt),
"",
true,
true,
true,
true,
jwtService.getAuthorities(jwt)
);
//UsernamePasswordAuthenticationToken 대상을 생성 (사용자이름,암호(=null로 설정),권한)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
//authenticationToken의 세부정보 설정
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//해당 인증 객체를 SecurityContextHolder에 authenticationToken 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//헤더에 accessToken 유효하므로 동일하게 설정
response.setHeader("Authorization", "Bearer " + jwt);
}
@Service
public class JwtService {
/**
* 클레임(Claim): JWT(토큰 기반의 웹 인증 시스템) 내에서 사용자에 대한 정보를 나타내는 JSON 객체
*/
@Value("${custom.jwt.secretKey}")
private String secretKeyPlain;
/**
* 비밀 키 : JWT에서 사용되는 비밀 키
*/
private SecretKey getSignInKey() {
// decode SECRET_KEY
return Keys.hmacShaKeyFor(secretKeyPlain.getBytes(StandardCharsets.UTF_8));
}
/**
* 토큰의 사용자 이름 추출
*/
public String extractUsername(String token) {
return String.valueOf(extractAllClaims(token).get("id"));
}
/**
* 토큰의 사용자 권한 추출
*/
public String extractAuthority(String token) {
return (String) extractAllClaims(token).get("authorities");
}
/**
* activeToken에서 모든 클레임을 추출하는 작업
*/
public Claims extractAllClaims(String token) {
return Jwts
.parser()
//verifyWith(): key 같은 값을 보냄
.verifyWith(getSignInKey())
.build()
//parseSignedClaims(): 받은 JWT 토큰 보냄
.parseSignedClaims(token.trim())
//JWT 바디 값을 읽어보자, 특정 값을 나타내는 토큰 값이라면 헤더에서 서명 부분을 읽고 싶지 않은 것이다
//getPayload() 메소드에서 claims를 가져옴
.getPayload();
}
/**
* 계정 활성화 토큰 (activeToken) 생성
*/
public String generateActiveToken(UserDetails userDetails) {
return Jwts
.builder().issuer("Skill Exchange").subject("JWT Active Token")
//claim(): 로그인된 유저의 ID를 채워줌
.claim("id", userDetails.getUsername())
//issuedAt(): 클라이언트에게 JWT 토큰이 발행시간 설정
.issuedAt(new Date())
//expiration(): 클라이언트에게 JWT 토큰이 만료시간 설정 (5분)
.expiration(new Date((new Date()).getTime() + 5 * 60 * 1000))
//signWith(): JWT 토큰 속 모든 요청에 디지털 서명을 하는 것, 여기서 위에서 설정한 비밀키를 대입
.signWith(getSignInKey()).compact();
}
/**
* 엑세스 토큰 (accessToken) 생성
*/
public String generateAccessToken(UserDetails userDetails) {
return Jwts
.builder().issuer("Skill Exchange").subject("JWT Access Token")
//claim(): 로그인된 유저의 ID, 권한을 채워줌
.claim("id", userDetails.getUsername())
.claim("authorities",populateAuthorities(userDetails.getAuthorities()))
//issuedAt(): 클라이언트에게 JWT 토큰이 발행시간 설정
.issuedAt(new Date())
//expiration(): 클라이언트에게 JWT 토큰이 만료시간 설정 (1시간)
.expiration(new Date((new Date()).getTime() + (60 * 60 * 1000)))
//signWith(): JWT 토큰 속 모든 요청에 디지털 서명을 하는 것, 여기서 위에서 설정한 비밀키를 대입
.signWith(getSignInKey()).compact();
}
/**
* 사용자 이름과 사용자 세부 정보를 기반으로 토큰이 유효한지 여부
*/
public boolean isActiveTokenValid(String token, UserDetails userDetails) {
final String id = extractUsername(token);
return (id.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
/**
* 토큰 만료 여부
*/
public boolean isTokenExpired(String token) {
try {
Date expirationDate = extractExpiration(token);
return expirationDate != null && expirationDate.before(new Date());
} catch (ExpiredJwtException e) {
// 토큰이 만료되었음을 처리
return true;
}
}
/**
* 토큰에서 만료 일자 클레임을 추출하여 반환
*/
public Date extractExpiration(String token) {
return extractAllClaims(token).getExpiration();
}
/**
* user의 authority 여러 개일 수도 있음을 고려한 String 변경
*/
private String populateAuthorities(Collection<? extends GrantedAuthority> collection) {
Set<String> authoritiesSet = new HashSet<>();
//내 모든 권한을 읽어옴
for (GrantedAuthority authority : collection) {
authoritiesSet.add(authority.getAuthority());
}
//String value로 "," 를 구분자로 권한들을 구분
return String.join(",", authoritiesSet);
}
/**
* 반대로 권한이 String으로 들어올 때 List<GrantedAuthority>로 반환
*/
public Collection<? extends GrantedAuthority> getAuthorities(String token) {
String strAuthorities = extractAuthority(token);
// 하나의 권한만 있는 경우
if (!strAuthorities.contains(",")) {
return Collections.singletonList(new SimpleGrantedAuthority(strAuthorities));
}
StringTokenizer st = new StringTokenizer(strAuthorities, ",");
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
while (st.hasMoreTokens()) {
grantedAuthorities.add(new SimpleGrantedAuthority(st.nextToken()));
}
return grantedAuthorities;
}
public void validateToken(String token) {
try {
Jwts.parser()
//verifyWith(): key 같은 값을 보냄
.verifyWith(getSignInKey())
.build()
//parseSignedClaims(): 받은 JWT 토큰 보냄
.parseSignedClaims(token)
//JWT 바디 값을 읽어보자, 특정 값을 나타내는 토큰 값이라면 헤더에서 서명 부분을 읽고 싶지 않은 것이다
//getPayload() 메소드에서 claims를 가져옴
.getPayload();
} catch (JwtException | IllegalArgumentException e) {
throw UserTokenExpriedException.EXCEPTION;
}
package place.skillexchange.backend.user.repository;
import org.springframework.data.repository.CrudRepository;
import place.skillexchange.backend.user.entity.Refresh;
public interface RefreshRepository extends CrudRepository<Refresh, String> {
Refresh findByRefreshToken(String refreshToken);
}
package place.skillexchange.backend.user.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@RedisHash(value = "refresh", timeToLive = 1209600) //2주
public class Refresh {
@Id
private String userId;
@Indexed
private String refreshToken;
}
현재 accessToken
은 24시간, refreshToken
은 2주 정도로 설정하였다.
.addFilterBefore(authFilterService, UsernamePasswordAuthenticationFilter.class)
AuthFilterService
는Spring Security 필터 체인
에 사용자 정의 필터를 추가하였다.
이는 요청을 필터링하고 사용자를 인증하는 역할을 수행한다. 이 필터는 UsernamePasswordAuthenticationFilter
앞에 추가되어 실행된다.
UsernamePasswordAuthenticationFilter
는 Spring Security에서 사용자의 이름과 비밀번호를 사용하여 인증을 수행하는 기본 인증 필터 중 하나이다. 이 필터는 사용자가 로그인 페이지로부터 제출한 사용자 자격 증명을 처리하고, 해당 사용자가 유효한지 확인하는 역할을 한다.
따라서 addFilterBefore() 구문
은 사용자 정의 필터가 기본적인 사용자 이름 및 비밀번호 인증 필터 이전에 실행되도록 한다. 이렇게 함으로써 사용자 정의 필터는 인증 절차를 시작하기 전에 요청을 먼저 처리할 수 있다.
AuthFilterService
의doFilterInternal 메서드
를 살펴보자.
오버라이드 된 doFilterInternal 메서드
는 OncePerRequestFilter
클래스를 상속받아서 구현되었으므로 모든 요청에 대해 한 번씩 호출된다.
먼저, 클라이언트 요청에서 헤더에서 JWT 토큰을 추출한다.
추출된 토큰이 있고, 해당 요청에 대한 인증이 아직 이루어지지 않았다면 토큰의 유효성을 검사하고 사용자를 인증한다.
만약 JWT 토큰이 만료
되었다면 handleExpiredToken 메서드
를 호출하여 리프레시 토큰을 사용하여 액세스 토큰을 재발급한다.
handleExpiredToken 메서드
를 자세히 살펴보면 다음과 같다.
만료된 엑세스 토큰을 처리하기 위해 쿠키로 받은 리프레시 토큰과 동일한 DB에 저장된 리프레시 토큰을 찾아 유효기한이 지나지 않았는지 체크한다.
만약 유효기한이 지난 리프레시 토큰이라면 "Refresh Token이 만료되었습니다."
라고 Exception이 발생하고, 클라이언트에겐 "계정에 다시 로그인 해야 합니다."
라는 오류 메세지를 전달한다.
유효기한이 지나지 않은 리프레시 토큰이라면 새로운 액세스 토큰을 발급하고, 이를 헤더에 설정한다. 그리고 사용자를 인증하여 SecurityContextHolder
에 사용자 정보를 설정한다.
accessToken
이 만료되지 않았다면 authenticateUser()
메서드가 실행되어 주어진 JWT를 사용하여 사용자를 인증한다. 그리고 인증된 사용자의 정보를 SecurityContextHolder
에 설정한 후 새로 생성된 액세스 토큰을 클라이언트에게 반환한다.
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ErrorResponse access_denied = new ErrorResponse(UserErrorCode.ACCOUNT_LOGIN_REQUIRED.getErrorReason(), request.getRequestURI().toString());
responseToClient(response, access_denied);
}
private void responseToClient(HttpServletResponse response, ErrorResponse errorResponse)
throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(errorResponse.getStatus());
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
ErrorResponse access_denied = new ErrorResponse(UserErrorCode.USER_ACCESS_DENIED.getErrorReason(), request.getRequestURI().toString());
responseToClient(response, access_denied);
}
private void responseToClient(HttpServletResponse response, ErrorResponse errorResponse)
throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(errorResponse.getStatus());
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
@Getter
@AllArgsConstructor
public enum UserErrorCode implements BaseErrorCode {
@ExplainError("인증 및 권한이 없는 경우")
USER_TOKEN_EXPIRED(UNAUTHORIZED, "USER_401_1", "해당 토큰이 만료되었거나 올바른 형태가 아닙니다."),
ACCESSTOKEN_REQUIRED(UNAUTHORIZED, "USER_401_2", "액세스 토큰이 필요합니다"),
REFRESHTOKEN_NOT_FOUND(UNAUTHORIZED, "USER_401_2", "Refresh Token을 찾을 수 없습니다."),
REFRESHTOKEN_EXPIRED(UNAUTHORIZED, "USER_401_3", "Refresh Token이 만료되었습니다."),
USER_ACCESS_DENIED(FORBIDDEN, "USER_403_1", "접근이 거부되었습니다."),
@ExplainError("사용자 정보를 찾을 수 없는 경우")
USER_NOT_FOUND(BAD_REQUEST, "USER_400_1", "사용자 정보를 찾을 수 없습니다."),
USER_LOGIN_INVALID(BAD_REQUEST, "USER_400_2", "일치하는 로그인 정보가 없습니다."),
EMAIL_SEND_FAILURE(BAD_REQUEST, "USER_400_3", "이메일 전송 중 문제가 생겼습니다."),
SCRAP_NOT_FOUND(BAD_REQUEST,"USER_400_4","스크랩한 게시물이 없습니다."),
WRITER_LOGGEDINUSER_INVALID(INTERNAL_SERVER, "USER_500_1", "로그인한 회원 정보와 글쓴이가 다릅니다."),
ACCOUNT_LOGIN_REQUIRED(NOT_FOUND,"USER_404_1", "계정에 다시 로그인 해야 합니다."),
SOCIAL_LOGIN_REQUIRED(NOT_FOUND,"USER_404_2", "해당 소셜 로그인(카카오 혹은 구글) 계정으로 다시 로그인한 후 회원 탈퇴를 진행해주세요"),
USER_EMAIL_NOT_FOUND(NOT_FOUND, "USER_404_2", "등록된 계정 중 없는 이메일 주소 입니다.");
private Integer status;
private String code;
private String reason;
public ErrorReason getErrorReason() {
return ErrorReason.builder().message(reason).code(code).status(status).build();
}
}
package place.skillexchange.backend.common.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ErrorReason {
private final Integer status;
private final String code;
private final String message;
}
package place.skillexchange.backend.exception;
import place.skillexchange.backend.common.dto.ErrorReason;
public interface BaseErrorCode {
public ErrorReason getErrorReason();
}
CustomAuthenticationEntryPoint 클래스
commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
: 인증되지 않은 요청에 대한 처리를 담당한다. 404 상태 코드와 함께 에러 메시지를 반환한다.
CustomAccessDeniedHandler 클래스
handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
: 접근이 거부된 요청에 대한 처리를 담당한다. 403 상태 코드와 함께 에러 메시지를 반환한다.
참고로
AuthFilterService
에서 발생한 예외는 필터의 범위 내에서 처리되므로, 이 예외는 컨트롤러에 도달할 수 없다.나는 프로젝트에 전체 예외 처리를
GlobalExceptionHandler
라는ResponseEntityExceptionHandler
를 상속한 RestController를 만들어 사용하고 있다.앞서 말했듯이
AuthFilterService
에서 발생한 예외는GlobalExceptionHandler
에서 처리가 불가능하기 때문에SecurityConfig
클래스에서.exceptionHandling() 메서드
를 사용하여 예외 처리를 구성하고 있다.이 부분에서
accessDeniedHandler
와authenticationEntryPoint
를 설정하고 있다.
[재능교환소] @ExceptionHandler 예외처리
회원가입 시에는 유효성 검사가 필수이다.
유효성 검사가 성공하면 계정활성화
를 위해 ActiveToken
을 발급하여 사용자에게 이메일을 보낸다. 이때 이메일 내용 중 버튼을 클릭하면 이동하는 링크(Url)에 ActiveToken
을 포함시켜 전송한다.
@Entity
@Table(name = "users")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class User implements UserDetails {
@Id
@Column(name = "user_id", length = 50, unique = true, nullable = false)
private String id;
@Column(name = "password", length = 100)
private String password;
@Column(name = "email", length = 50)
private String email;
@Column(name = "active")
@ColumnDefault("0")
private boolean active;
@Enumerated(EnumType.STRING)
@Column(length = 6)
private Gender gender;
@Column(name = "job", length = 50)
private String job;
@Column(name = "career_skills", length = 100)
private String careerSkills;
@Column(name = "preferred_subject", length = 50)
private String preferredSubject;
@Column(name = "my_subject", length = 50)
private String mySubject;
/**
* Security에서 권한 정보 로드할 때 LAZY(지연)로딩이 아니라 EAGER(즉시)로딩으로 설정해야 함
*/
@ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinTable(
name = "user_authority",
joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")},
inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
private Set<Authority> authorities;
/**
* User와 File은 1:1 관계
*/
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
private File file;
/**
* User와 TalentScrap 양방향 매핑
*/
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private Set<TalentScrap> talentScraps = new HashSet<>();
@Enumerated(EnumType.STRING)
@Column(name = "provider", length = 20)
private AuthProvider provider;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
for (Authority authority : authorities) {
grantedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthorityName()));
}
return grantedAuthorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return id;
}
/**
* active 컬럼 0->1 변경
*/
public void changeActive(boolean active) {
this.active = active;
}
/**
* 임시 password
*/
public void changePw(String password) {
this.password = password;
}
/**
* 프로필 수정
*/
public void changeProfileField(UserDto.ProfileRequest dto) {
this.gender = Gender.valueOf(dto.getGender());
this.job = dto.getJob();
this.careerSkills = dto.getCareerSkills();
this.preferredSubject = dto.getPreferredSubject();
this.mySubject = dto.getMySubject();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Spring data jpa를 사용하므로 User 엔티티를 만들어 주었다.
여기서 살펴볼 점은 Authority
와 1:N, N:1로 중간 테이블을 Set으로 만들어 설계하였다. Security에서 권한 정보 로드 시 Authority
정보를 받아올 수 있게 LAZY(지연)로딩이 아니라 EAGER(즉시)로딩으로 설정하였다.
그리고 RefreshToken
과 File
엔티티와는 1:1 관계이고, TalaentScrap
와는 1:N 관계 이다.
앞전에 말했듯이 Spring Security로 User 엔티티에 UserDetails
를 구현해 주었다.
참고로 로그인, 회원가입, activeToken 발급, 회원탈퇴 등은 AuthService에서 구현하였다.
(그 외 회원 기능은 UserService)
package place.skillexchange.backend.user.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import place.skillexchange.backend.user.entity.User;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, String> {
//이메일 찾기
Optional<User> findByEmail(String email);
//활성화된 사용자(계정) 반환
User findByIdAndActiveIsTrue(String id);
//email와 id가 일치하는 활성화되지 않은 사용자
Optional<User> findByEmailAndIdAndActiveIsFalse(String email, String id);
}
public class UserDto {
/**
* 회원가입 시 요청된 Dto
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class SignUpRequest {
@NotBlank(message = "아이디: 필수 정보입니다.")
@Size(min = 5 , message="id는 5글자 이상 입력해 주세요.")
private String id;
@NotBlank(message = "이메일: 필수 정보입니다.")
@Email(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
private String email;
@NotBlank(message = "비밀번호: 필수 정보입니다.")
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
private String password;
@NotBlank(message = "비밀번호 확인: 필수 정보입니다.")
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
private String passwordCheck;
//Authority 객체를 생성하고, 권한 이름을 "ROLE_USER"로 설정
Authority authority = Authority.builder()
.authorityName("ROLE_USER")
.build();
/* Dto -> Entity */
//toEntity는 패스워드 확인 일치하면 사용
public User toEntity() {
User user = User.builder()
.id(id)
.email(email)
.password(password)
.authorities(Collections.singleton(authority))
.build();
return user;
}
}
/**
* 회원가입, 로그인 성공시 보낼 Dto
*/
@Getter
public static class SignUpInResponse {
private String id;
private String email;
private int returnCode;
private String returnMessage;
/* Entity -> Dto */
public SignUpInResponse(User user, int returnCode, String returnMessage) {
this.id = user.getId();
this.email = user.getEmail();
this.returnCode = returnCode;
this.returnMessage = returnMessage;
}
...
}
package place.skillexchange.backend.auth.services;
import jakarta.mail.MessagingException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import place.skillexchange.backend.user.dto.UserDto;
import java.io.IOException;
public interface AuthService {
public UserDto.SignUpInResponse register(UserDto.SignUpRequest dto, BindingResult bindingResult) throws MethodArgumentNotValidException, MessagingException, IOException;
public boolean validateDuplicateMember(UserDto.SignUpRequest dto, BindingResult bindingResult);
}
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final JwtService jwtService;
private final MailService mailService;
/* 회원가입 ~ 로그인 까지 (JWT 생성) */
/**
* 회원가입
*/
@Override
public UserDto.SignUpInResponse register(UserDto.SignUpRequest dto, BindingResult bindingResult) throws MethodArgumentNotValidException, MessagingException, IOException {
boolean isValid = validateDuplicateMember(dto, bindingResult);
if (isValid) {
throw new MethodArgumentNotValidException(null, bindingResult);
}
dto.setPassword(passwordEncoder.encode(dto.getPassword()));
//user 저장
User user = userRepository.save(dto.toEntity());
//5분 뒤 회원의 active가 0이라면 db에서 회원 정보 삭제 (active 토큰 만료일에 맞춰서)
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// 5분 후에 실행될 작업
if (userRepository.findByIdAndActiveIsTrue(user.getId()) == null) {
userRepository.delete(user);
}
timer.cancel(); // 작업 완료 후 타이머 종료
}
}, 5 * 60 * 1000); // 5분 후에 작업 실행
String activeToken = jwtService.generateActiveToken(user);
//active Token (계정 활성화 토큰) 발급
mailService.getEmail(dto.getEmail(), dto.getId(), activeToken);
return new UserDto.SignUpInResponse(user, 201, "이메일(" + dto.getEmail() + ")을 확인하여 회원 활성화를 완료해주세요.");
}
/**
* 회원가입 검증
*/
@Override
@Transactional
public boolean validateDuplicateMember(UserDto.SignUpRequest dto, BindingResult bindingResult) {
boolean checked = false;
//checked가 true면 검증 발견
//checked가 false면 검증 미발견
checked = bindingResult.hasErrors();
//방법1. 동일 id와 email만 계속해서 접근 가능 , 동일 id이나 email이 다르면 접근 불가능 / 동일 email이나 id가 다르면 접근 불가능 (유효성검사)
//active가 0이고, id와 email이 db에 있는 경우엔 if문을 건너뛴다.
Optional<User> userOptional = userRepository.findByEmailAndIdAndActiveIsFalse(dto.getEmail(), dto.getId());
if (!userOptional.isPresent()) {
//id가 db에 있는 경우 if문 실행
if(userRepository.findById(dto.getId()) != null) {
//id 중복 검증
Optional<User> byId = userRepository.findById(dto.getId());
if (!byId.isEmpty()) {
bindingResult.rejectValue("id", "user.id.notEqual");
checked = true;
}
}
//email이 db에 있는 경우 if문 실행
if(userRepository.findByEmail(dto.getEmail()) != null) {
//email 중복 검증
Optional<User> userEmail = userRepository.findByEmail(dto.getEmail());
if (userEmail.isPresent()) {
bindingResult.rejectValue("email", "user.email.notEqual");
checked = true;
}
}
}
//password 일치 검증
if (!dto.getPasswordCheck().equals(dto.getPassword())) {
bindingResult.rejectValue("passwordCheck", "user.password.notEqual");
checked = true;
}
return checked;
}
회원가입 검증을 보면 id와 email의 중복 검증, password 일치 불일치 검증을 진행하고 있다.
이전에 말한 이메일로 계정활성화 이메일이 인증되면 DB에서는 active
컬럼이 0에서 1
로 변환된다.
active가 1인 순간
에 로그인
이 가능하도록 코드 구현을 하였기에, 유효성 검사 시에 이 부분은 중요하게 작용하였다.
유효성 검사의 선두 조건은 id와 email이 db에서 존재하는지 찾되 atctive는 0인 컬럼이어야 한다.
Optional<User> userOptional = userRepository.findByEmailAndIdAndActiveIsFalse(dto.getEmail(), dto.getId());
만약 반환된 Optional이 비어있다면 db에 id가 존재하는지 찾고, 존재한다면 이미 존재하는 아이디입니다.
를 bindingResult에 오류 문구 추가하고, email도 마찬가지로 중복 검사 후에 존재한다면 이미 존재하는 이메일입니다.
를 bindingResult에 오류 문구 추가해준다.
@Service
public class JwtService {
private static final String SECRET_KEY = "jxgEQeXHuPq8VdbyYFNkANdudQ53YUn20233359";
/**
* 비밀 키 : JWT에서 사용되는 비밀 키
*/
private SecretKey getSignInKey() {
// decode SECRET_KEY
return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
}
/**
* 계정 활성화 토큰 (activeToken) 생성
*/
public String generateActiveToken(UserDetails userDetails) {
return Jwts
.builder().issuer("Skill Exchange").subject("JWT Active Token")
//claim(): 로그인된 유저의 ID를 채워줌
.claim("id", userDetails.getUsername())
//issuedAt(): 클라이언트에게 JWT 토큰이 발행시간 설정
.issuedAt(new Date())
//expiration(): 클라이언트에게 JWT 토큰이 만료시간 설정 (5분)
.expiration(new Date((new Date()).getTime() + 5 * 60 * 1000))
//signWith(): JWT 토큰 속 모든 요청에 디지털 서명을 하는 것, 여기서 위에서 설정한 비밀키를 대입
.signWith(getSignInKey()).compact();
}
}
유효성 검사 통과 이후엔 db에 active 컬럼이 0인 상태로 저장해 둔 뒤 5분 뒤에는 db에서 회원 정보 삭제하도록 만들었다.
Active Token 발급 시 유효기간을 5분으로 설정해 놓았기 때문에 DB에서도 정보가 지워져야하는 것이다.
발급한 Active Token의 Payload
를 살펴보면 사용자의 id, 발행시간은 현재시간, 만료시간은 현재시간+5분
으로 설정하였다.
하단 링크로 들어가면 이전에 내가 정리해둔 SpringBoot에서 JWT를 구현 방법이 나와있다.
package place.skillexchange.backend.common.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.IOException;
import java.util.HashMap;
@Service
@RequiredArgsConstructor
public class MailService {
private final TemplateEngine templateEngine;
private final JavaMailSender emailSender;
@Value("${spring.mail.username}")
private String sender;
public void getEmail(String email, String id, String activeToken) throws MessagingException, IOException {
MimeMessage message = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
//메일 제목 설정
helper.setSubject("재능교환소 계정 활성화 인증");
//수신자 설정
helper.setTo(email);
//송신자 설정
helper.setFrom(sender);
//템플릿에 전달할 데이터 설정
HashMap<String, String> emailValues = new HashMap<>();
emailValues.put("id", id);
emailValues.put("jwtLink", "http://localhost:3000/active/"+activeToken);
Context context = new Context();
emailValues.forEach((key, value)->{
context.setVariable(key, value);
});
//메일 내용 설정 : 템플릿 프로세스
String html = templateEngine.process("email_activation", context);
helper.setText(html, true);
//템플릿에 들어가는 이미지 cid로 삽입
helper.addInline("image", new ClassPathResource("static/img/Logo.png"));
//메일 보내기
emailSender.send(message);
}
}
getEmail
을 살펴보면 계정활성화 이메일을 보내기 위해 emil_activation.html에 넘겨줄 데이터들을 설정해주고 있다. 이 부분은 타임리프로 구현하였다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>재능 교환소 계정 활성화 인증 메일</title>
</head>
<body>
<div class="container">
<img src='cid:image' alt="로고 이미지">
<h2>안녕하세요, <span th:text="${id}"></span>님</h2>
<p>누군가가 새로운 위치에서 회원님의 계정으로 접속을 시도하고 있어요.</p>
<p>본인이라면 아래 버튼을 클릭하여 계정을 활성화 해주세요.</p>
<a th:href="${jwtLink}">
<button type="button">계정 활성화</button>
</a>
</div>
</body>
</html>
cloud:
aws:
s3:
bucket: skillexcahnge
stack.auto: false
region.static: ap-northeast-2
credentials:
accessKey: (key는 보안을 위해 지움)
secretKey: (key는 보안을 위해 지움)
spring:
mail:
host: smtp.gmail.com
port: 587
username: kimmin7932@gmail.com
password: (password는 보안을 위해 지움)
properties:
mail:
smtp:
auth: true
starttls:
enable: true
custom:
jwt:
secretKey: (key는 보안을 위해 지움)
application.yml에 적으면 보안위협
이 있는 요소들은 application-sub.yml에 작성하였다.
application.yml
에서
spring:
profiles:
include: sub
를 명시해 두고,
.gitignore
에
### IntelliJ IDEA ###
application-sub.yml
를 명시해 두었다.
이렇게 하면 github에 올려도 application-sub.yml은 자동으로 올라가지 않는다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user/")
@Slf4j
public class UserController {
private final AuthService authService;
/**
* 회원가입
*/
@PostMapping("/signUp")
public ResponseEntity<UserDto.SignUpInResponse> register(@Validated @RequestBody UserDto.SignUpRequest dto, BindingResult bindingResult) throws MethodArgumentNotValidException, MessagingException, IOException {
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(dto, bindingResult));
}
...
}
Controller
에서는 기능적인 요소를 작성하지 않는다. 클라이언트의 요청을 받아들이고 해당 요청에 대한 응답을 반환한다.
눈여겨 볼 점은 검증을 위해 @Validated
와 BindingResult
를 매개변수로 받는다는 점이다.
만약 유효성 검사에서 문제가 발생한다면 bindingResult에 에러가 담긴다.
그리고 해당 에러를 처리하기 위해 MethodArgumentNotValidException 예외
와 MessagingException 예외
를 던진다.
나는 예외 처리 부분을 AOP를 활용하여 ExceptionHandler
가 적절히 동작하게 된다.
@RestController
@ControllerAdvice //AOP
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(Exception.class) //타 Controller 실행 중 Exception 에러 발생 시 handlerAllExceptions()가 작업 우회
public final ResponseEntity<Object> handlerAllExceptions(Exception ex, WebRequest request) {
ServletWebRequest servletWebRequest = (ServletWebRequest) request;
String url =
UriComponentsBuilder.fromUri(
new ServletServerHttpRequest(servletWebRequest.getRequest()).getURI())
.build()
.toUriString();
log.error("INTERNAL_SERVER_ERROR", ex);
GlobalErrorCode internalServerError = GlobalErrorCode.INTERNAL_SERVER_ERROR;
ErrorResponse errorResponse =
new ErrorResponse(
internalServerError.getStatus(),
internalServerError.getCode(),
internalServerError.getReason(),
url);
return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatus()))
.body(errorResponse);
}
/**
* 이메일 전송 시 오류
*/
@ExceptionHandler(MessagingException.class) //타 Controller 실행 중 MessagingException 에러 발생 시 handlerInValidEmailException()가 작업 우회
public final ResponseEntity<Object> handlerInValidEmailException(HttpServletRequest request) {
ErrorResponse access_denied = new ErrorResponse(UserErrorCode.EMAIL_SEND_FAILURE.getErrorReason(), request.getRequestURI().toString());
return new ResponseEntity(access_denied, HttpStatus.BAD_REQUEST);
}
/**
* 유효성 검사 실패
*/
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors();
ServletWebRequest servletWebRequest = (ServletWebRequest) request;
String url =
UriComponentsBuilder.fromUri(
new ServletServerHttpRequest(servletWebRequest.getRequest()).getURI())
.build()
.toUriString();
// 에러 메시지 목록
List<String> errorMessages = new ArrayList<>();
for (FieldError fieldError : errors) {
String errorMessage = messageSource.getMessage(fieldError, Locale.getDefault());
if (errorMessage != null) {
errorMessages.add(errorMessage);
}
}
ValidationException errorResponse =
new ValidationException(status.value(), "유효성 검사 실패", status.toString(), errorMessages, url);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
/**
* 전체 eception 처리
*/
@ExceptionHandler(AllCodeException.class)
public ResponseEntity<ErrorResponse> CodeExceptionHandler(
AllCodeException e, HttpServletRequest request) {
BaseErrorCode code = e.getErrorCode();
ErrorReason errorReason = code.getErrorReason();
ErrorResponse errorResponse =
new ErrorResponse(errorReason, request.getRequestURL().toString());
return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
.body(errorResponse);
}
}
package place.skillexchange.backend.common.dto;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
@Getter
public class ValidationException {
private final boolean success = false;
private int status;
private String code;
private String message;
private List<String> details;
private LocalDateTime timeStamp;
private String path;
public ValidationException(int status, String code, String message, List<String> details, String path) {
this.status = status;
this.code = code;
this.message = message;
this.details = details;
this.timeStamp = LocalDateTime.now();
this.path = path;
}
}
위 과정이 완료되면 db에는 회원 정보가 저장된다. 앞서 말했듯이 db에는 active 컬럼이 0인 상태로 회원 정보가 저장되고, 5분이 지나고 인증 토큰이 들어오지 않으면 회원 정보를 삭제하도록 만들었다.
인증 토큰이 들어왔을 때 로직을 살펴보자
이번엔 컨트롤러부터 살펴보자
package place.skillexchange.backend.user.controller;
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user/")
@Slf4j
public class UserController {
private final AuthService authService;
private final MailService mailService;
private final UserService userService;
/**
* active Token (계정 활성화 토큰) 검증
*/
@PostMapping("/activation")
public UserDto.ResponseBasic activation(@RequestBody Map<String, String> requestBody) {
return authService.activation(requestBody);
}
}
회원가입 후에 유효성 검사까지 성공되면 계정활성화
를 위해 ActiveToken
을 발급하여 사용자에게 이메일을 보낸다고 하였다.
이메일이 도착하고 로그인 인증
버튼을 클릭하면 아래와 같은 형태로 URL이 요청된다.
(localhost는 배포 시 새로운 DNS주소로 변경될 예정)
이때 포트 번호가 3000번임을 감안하면 프론트엔드(=리액트)에게 요청된다는 것을 알 수 있다.
프론트엔드를 URL을 파싱해 actvie 뒤에 들어오는 파라미터인 jwt 문자열을 파싱하여 RequestBody에 포함하여 백엔드에게 /v1/user/activation
주소로 요청을 보낼 것이다.
PostMan으로 테스트를 돌려보면 이와 같은 형태일 것이다.
전체적인 흐름을 파악했으니 Service 부분을 살펴보자.
package place.skillexchange.backend.auth.services;
public interface AuthService {
public UserDto.ResponseBasic activation(Map<String, String> requestBody);
}
package place.skillexchange.backend.auth.services;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final UserRepository userRepository;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
/**
* activeToken 발급
*/
@Override
@Transactional
public UserDto.ResponseBasic activation(Map<String, String> requestBody) {
String activeToken = requestBody.get("activeToken");
String id = jwtService.extractUsername(activeToken);
UserDetails userDetails = userDetailsService.loadUserByUsername(id);
// 여기서 activeToken을 검증하고 처리하는 로직을 추가
//isTokenValid가 false일때 토큰 만료 exception이 출력되어야 함 !!!
if (!jwtService.isActiveTokenValid(activeToken, userDetails)) {
// 토큰이 유효하지 않은 경우 예외를 발생시킴
throw UserTokenExpriedException.EXCEPTION;
}
// active 0->1 로 변경 (active가 1이여야 로그인 가능)
updateUserActiveStatus(id);
return new UserDto.ResponseBasic(200, "계정이 활성화 되었습니다.");
}
/**
* active 컬럼 0->1 변경
*/
@Transactional
@Override
public void updateUserActiveStatus(String id) {
User user = userRepository.findById(id).orElseThrow(() -> UserNotFoundException.EXCEPTION);
user.changeActive(true);
//userRepository.save(user);
}
}
package place.skillexchange.backend.auth.services;
@Service
public class JwtService {
/**
* 클레임(Claim): JWT(토큰 기반의 웹 인증 시스템) 내에서 사용자에 대한 정보를 나타내는 JSON 객체
*/
@Value("${custom.jwt.secretKey}")
private String secretKeyPlain;
/**
* 비밀 키 : JWT에서 사용되는 비밀 키
*/
private SecretKey getSignInKey() {
// decode SECRET_KEY
return Keys.hmacShaKeyFor(secretKeyPlain.getBytes(StandardCharsets.UTF_8));
}
/**
* activeToken에서 모든 클레임을 추출하는 작업
*/
private Claims extractAllClaims(String token) {
return Jwts
.parser()
//verifyWith(): key 같은 값을 보냄
.verifyWith(getSignInKey())
.build()
//parseSignedClaims(): 받은 JWT 토큰 보냄
.parseSignedClaims(token)
//JWT 바디 값을 읽어보자, 특정 값을 나타내는 토큰 값이라면 헤더에서 서명 부분을 읽고 싶지 않은 것이다
//getPayload() 메소드에서 claims를 가져옴
.getPayload();
}
/**
* 토큰의 사용자 이름 추출
*/
public String extractUsername(String token) {
return String.valueOf(extractAllClaims(token).get("id"));
}
/**
* 토큰에서 만료 일자 클레임을 추출하여 반환
*/
private Date extractExpiration(String token) {
return extractAllClaims(token).getExpiration();
}
/**
* 토큰 만료 여부
*/
public boolean isTokenExpired(String token) {
try {
Date expirationDate = extractExpiration(token);
return expirationDate != null && expirationDate.before(new Date());
} catch (ExpiredJwtException e) {
// 토큰이 만료되었음을 처리
return true;
}
}
/**
* 사용자 이름과 사용자 세부 정보를 기반으로 토큰이 유효한지 여부
*/
public boolean isActiveTokenValid(String token, UserDetails userDetails) {
final String id = extractUsername(token);
return (id.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
Service에서는 해당 토큰이 유효한지 유효하지 않은지 체크한 후 db에 active 컬럼을 0에서 1로 변경해준다.
이렇게 만드는 이유는 회원가입 후 5분이 지났을 때 active컬럼이 0이면 회원 정보를 지우고 1이면 지우지 않기에 매우 중요한 역할을 한다.
또한 atctvie 컬럼이 1인 튜플만이 로그인이 가능하도록 구현하였다.
여기서도 토큰이 유효하지 않다면 또는 DB에 사용자가 없다면 에러가 발생하도록 하였다.
package place.skillexchange.backend.exception.user;
import place.skillexchange.backend.exception.AllCodeException;
//RunTimeException(500번) 예외 클래스 상속받아서 생성
public class UserTokenExpriedException extends AllCodeException {
public static final AllCodeException EXCEPTION = new UserTokenExpriedException();
private UserTokenExpriedException() {
super(UserErrorCode.USER_TOKEN_EXPIRED);
}
}
토큰 만료 시 "토큰이 만료 되었습니다."를 ResponseBody로 전달
package place.skillexchange.backend.exception.user;
import place.skillexchange.backend.common.annotation.ApiErrorCodeExample;
import place.skillexchange.backend.common.dto.ErrorReason;
import place.skillexchange.backend.exception.AllCodeException;
public class UserNotFoundException extends AllCodeException {
public static final AllCodeException EXCEPTION = new UserNotFoundException();
private UserNotFoundException() {
super(UserErrorCode.USER_NOT_FOUND);
}
@ApiErrorCodeExample(UserErrorCode.class)
public ErrorReason getErrorReason() {
return super.getErrorReason();
}
}
db에 없는 사용자일시 "사용자 정보를 찾을 수 없습니다."를 ResponseBody로 전달
** UserErrorCode와 ErrorReason, BaseErrorCode, GlobalExceptionHandler는 Security 위 코드와 동일하므로 생략
package place.skillexchange.backend.user.controller;
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user/")
@Slf4j
@Tag(name = "user-controller", description = "일반 사용자를 위한 컨트롤러입니다.")
public class UserController {
private final AuthService authService;
/**
* 사용자 로그인
*/
@Operation(summary = "사용자 로그인 API", description = "활성화 토큰 검증 이후 로그인이 가능하다.")
@PostMapping("/signIn")
public ResponseEntity<UserDto.SignUpInResponse> login(@RequestBody UserDto.SignInRequest dto) {
return authService.login(dto);
}
}
package place.skillexchange.backend.user.dto;
public class UserDto {
/**
* 로그인시 요청된 Dto
*/
@Getter
@AllArgsConstructor
@Builder
public static class SignInRequest {
private String id;
private String password;
}
/**
* 회원가입, 로그인 성공시 보낼 Dto
*/
@Getter
public static class SignUpInResponse {
private String id;
private String email;
private int returnCode;
private String returnMessage;
/* Entity -> Dto */
public SignUpInResponse(User user, int returnCode, String returnMessage) {
this.id = user.getId();
this.email = user.getEmail();
this.returnCode = returnCode;
this.returnMessage = returnMessage;
}
}
}
package place.skillexchange.backend.auth.services;
public interface AuthService {
public ResponseEntity<UserDto.SignUpInResponse> login(UserDto.SignInRequest dto);
}
package place.skillexchange.backend.auth.services;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService{
private final UserRepository userRepository;
private final JwtService jwtService;
private final RefreshRepository refreshRepository;
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
/**
* 로그인
*/
@Override
public UserDto.SignUpInResponse login(UserDto.SignInRequest dto, HttpServletRequest request,
HttpServletResponse response) {
try {
//authenticationManager가 authenticate() = 인증한다.
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getId(),
dto.getPassword()
)
);
} catch (AuthenticationException ex) {
// 잘못된 아이디 패스워드 입력으로 인한 예외 처리
throw UserIdLoginException.EXCEPTION;
}
//유저의 아이디 및 계정활성화 유무를 가지고 유저 객체 조회
User user = userRepository.findByIdAndActiveIsTrue(dto.getId());
if (user == null) {
throw UserIdLoginException.EXCEPTION;
}
//accessToken 생성
String accessToken = jwtService.generateAccessToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
//RefreshToken 생성 (이미 있어도 덮어쓰기 가능)
Refresh redis = Refresh.builder()
.refreshToken(UUID.randomUUID().toString())
.userId(user.getId())
.build();
refreshRepository.save(redis);
Cookie cookie = new Cookie("refreshToken", redis.getRefreshToken());
cookie.setHttpOnly(true);
cookie.setSecure(true);
// 2주 후 만료일 설정
cookie.setMaxAge(60 * 60 * 24 * 14); // 초 단위로 설정
cookie.setPath("/");
cookie.setAttribute("SameSite", "None"); //쿠키에 samesite 속성 추가
response.addCookie(cookie);
return new UserDto.SignUpInResponse(user, 200, "로그인 성공!");
}
}
package place.skillexchange.backend.auth.services;
@Service
public class JwtService {
/**
* 클레임(Claim): JWT(토큰 기반의 웹 인증 시스템) 내에서 사용자에 대한 정보를 나타내는 JSON 객체
*/
@Value("${custom.jwt.secretKey}")
private String secretKeyPlain;
/**
* 비밀 키 : JWT에서 사용되는 비밀 키
*/
private SecretKey getSignInKey() {
// decode SECRET_KEY
return Keys.hmacShaKeyFor(secretKeyPlain.getBytes(StandardCharsets.UTF_8));
}
/**
* 엑세스 토큰 (accessToken) 생성
*/
public String generateAccessToken(UserDetails userDetails) {
return Jwts
.builder().issuer("Skill Exchange").subject("JWT Access Token")
//claim(): 로그인된 유저의 ID, 권한을 채워줌
.claim("id", userDetails.getUsername())
.claim("authorities",populateAuthorities(userDetails.getAuthorities()))
//issuedAt(): 클라이언트에게 JWT 토큰이 발행시간 설정
.issuedAt(new Date())
//expiration(): 클라이언트에게 JWT 토큰이 만료시간 설정 (5분)
.expiration(new Date((new Date()).getTime() + /*1 * 60 * 1000 */24 * 60 * 60 * 1000))
//signWith(): JWT 토큰 속 모든 요청에 디지털 서명을 하는 것, 여기서 위에서 설정한 비밀키를 대입
.signWith(getSignInKey()).compact();
}
}
package place.skillexchange.backend.user.repository;
import org.springframework.data.repository.CrudRepository;
import place.skillexchange.backend.user.entity.Refresh;
public interface RefreshRepository extends CrudRepository<Refresh, String> {
Refresh findByRefreshToken(String refreshToken);
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@RedisHash(value = "refresh", timeToLive = 1209600) //2주
public class Refresh {
@Id
private String userId;
@Indexed
private String refreshToken;
}
package place.skillexchange.backend.user.repository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, String> {
//활성화된 사용자(계정) 반환
User findByIdAndActiveIsTrue(String id);
}
로그인을 살펴보면 accessToken
과 refreshToken
을 발급하고 있는 것을 확인할 수 있다.
accessToken
은 만료일은 현재 시간으로부터 24시간 후, refreshToken
은 현재일로 부터 2주 후로 설정하였다.
accessToken
은 Authorization 헤더에 "Barear" 형식과 함께 JWT를 담아주었다.
refreshToken
은 쿠키에 담아 보내주고 있다.
앞서 말했듯이 인가가 필요한 URL을 서버에 요청하면 accessToken
이 유효하다면 인증 후 인가해주겠지만, 그렇지 않다면 DB에 저장된 refreshToken
과 쿠키의 refreshToken
으로 인증 후 인가해준다.