
Spring Boot, JWT 로그인 구현Back_End/Java2024. 4. 23. 23:15
Table of Contents
💡Spring Security 는 무엇인가?
- 자바 기반의 웹 애플리케이션을 위한 보안 프레임워크
- 주로 Spring 프레임워크를 사용하는 애플리케이션에서 인증과 권한 부여를 관리하기 위해 사용
- 사용자가 누구인지 확인하고(인증), 사용자가 특정 작업을 할 수 있는 원한이 있는지 확인하는(권한 부여) 시스템을 제공
- 인증 : 사용자가 자신이 주장하는 대로 실제 해당 사용자인지 확인하는 과정
- 권한 부여 : 인증된 사용자가 특정 리소스에 접근하거나 특정 작업을 수행할 권한이 있는지 결정하는 과정
- CSRF 보호 (Cross-Site Request Forgery) 기능 제공
- CSRF : 웹 사이트의 취약점을 이용하여 사용자가 의도하지 않은 요청을 전송하게 만드는 공격 방법
- 세션 관리 기능 제공 : 이를 통해 세션 고정, 세션 만료, 동시 세션 제어 등 세션 관련 보안을 강화할 수 있음
- 확장성 : 개발자의 필요에 따라 인증 공급자, 접근 결정 매니저, 비밀번호 정책 등을 구현하고 통합할 수 있음
본 포스팅에서는 Spring Boot 와 React 를 사용하여 구현할 예정이다.
시작하기 전 본 포스팅에 사용된 프로젝트의 기본 구조는 아래와 같다.
더보기
com.social.test
├── config
│ ├── auth
│ │ ├── LoginService
│ │ └── LoginUser
│ ├── jwt
│ │ ├── JwtAuthenticationFilter
│ │ ├── JwtAuthorizationFilter
│ │ ├── JwtProcess
│ │ └── JwtVo
│ └── SecurityConfig
├── controller
│ └── UserController
├── domain
│ ├── User
│ ├── UserEnum
│ └── UserRepository
├── dto
│ ├── user
│ │ ├── UserReqDto
│ │ └── UserRespDto
│ └── ResponseDto
├── handler
│ ├── aop
│ │ └── CustomValidationAdvice
│ ├── ex
│ │ ├── CustomApiExecption
│ │ ├── CustomValidationException
│ ├── CustomExceptionHandler
├── service
│ └── UserService
├── util
│ ├── CustomDateUtil
│ └── CustomResponseUtil
└── TestApplication
1. Spring Security 를 이용한 로그인 구현하기
1-1 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
1-2 SecurityConfig 생성
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Bean
public BCryptPasswordEncoder passwordEncoder() {
logger.debug("디버그 : BCryptPasswordEncoder 빈 등록됨");
return new BCryptPasswordEncoder();
}
// JWT 필터 등록이 필요함
public class CustomSecurityFilterManager extends AbstractHttpConfigurer<CustomSecurityFilterManager, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
builder.addFilter(new JwtAuthenticationFilter(authenticationManager));
builder.addFilter(new JwtAuthorizationFilter(authenticationManager));
super.configure(builder);
}
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
logger.debug("디버그 : filterChain 빈 등록됨");
http.headers().frameOptions().disable(); // iframe 허용안함.
http.csrf().disable();
http.cors().configurationSource(configurationSource());
// jSessionId를 서버쪽에서 관리안함
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.formLogin().disable();
// httpBasic은 브라우저가 팝업창을 이용해서 사용자 인증을 진행한다.
http.httpBasic().disable();
// 필터 적용
http.apply(new CustomSecurityFilterManager());
// 인증 실패
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
CustomResponseUtil.fail(response, "로그인을 진행해 주세요", HttpStatus.UNAUTHORIZED);
});
// 권한 실패
http.exceptionHandling().accessDeniedHandler((request, response, e) -> {
CustomResponseUtil.fail(response, "권한이 없습니다", HttpStatus.FORBIDDEN);
});
// 요청 권한 설정
http.authorizeRequests()
.antMatchers("/api/v1/**").authenticated()
.antMatchers("/api/admin/v1/**").hasRole("" + UserEnum.ADMIN)
.anyRequest().permitAll();
// 로그아웃
http.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
);
return http.build();
}
// CORS 설정
public CorsConfigurationSource configurationSource() {
logger.debug("디버그 : configurationSource cors 설정이 SecurityFilterChain에 등록됨");
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*"); // GET, POST, PUT, DELETE (Javascript 요청 허용)
configuration.addAllowedOriginPattern("*"); // 모든 IP 주소 허용
configuration.setAllowCredentials(true); // 클라이언트에서 쿠키 요청 허용
configuration.addExposedHeader("Authorization");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
- 본 포스팅에서는 FormLogin 방식을 사용하지 않음
- 요청 권한 설정
- authorizeRequest : 인증 / 인가가 필요한 endpoint 지정
- antMatchers
- authenticated : 작성한 endpoint 에 접근하기 위해서는 인증(로그인)을 해야함
- hasRole : 해당 권한이 있는 사용자만 접근 가능
- antMatchers
- authorizeRequest : 인증 / 인가가 필요한 endpoint 지정
- CORS 설정
- configuration
- addAllowedHeader : 모든 헤더 허용
- addAllowedMethod : 모든 HTTP 메서드 허용 (예: GET, POST, PUT, DELTE)
- addAllowedOriginPattern : 모든 원문 URL 패턴 허용
- setAllowedcredentials(true) : 자격증명과 함께 요청을 보낼 때 이를 허용함
- source
- registrationCorsConfiguration("/**", configuration) : 모든 경로에 대해 CORS 설정 적용
- configuration
1-3. User 엔티티 생성
@NoArgsConstructor
@Getter
@Setter
@EntityListeners(AuditingEntityListener.class)
@Entity
@ToString
@Table(name = "users")
public class User {
@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 사용자 아이디
@Column(unique = true, nullable = false, length = 50)
private String username;
// 사용자 성명
@Column(unique = true)
private String nickname;
// 비밀번호
@Column(nullable = true, length = 60)
private String password;
// 이메일
@Column(nullable = true, length = 30)
private String email;
// 권한
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserEnum role;
// 생성일
@CreatedDate
@Column(nullable = false)
private LocalDateTime createdAt;
// 수정일
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@Builder
public User(Long id, String username, String nickname, String name, String password, String email, UserEnum role, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.username = username;
this.nickname = nickname;
this.name = name;
this.password = password;
this.email = email;
this.role = role;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
1-4. UserEnum 생성
@AllArgsConstructor
@Getter
public enum UserEnum {
ADMIN("관리자"), CUSTOMER("고객");
private String value;
}
- 로그인 사용자의 권한 구분을 위한 enum
1-5. UserRepository 생성
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
Optional<User> findById(Long userId);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
boolean existsByNickname(String nickname);
void deleteById(Long userId);
}
1-6. LoginUser 생성
@Getter
@RequiredArgsConstructor
public class LoginUser implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> "ROLE_" + user.getRole());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getName() {
return null;
}
}
1-7. LoginService 생성
@Service
public class LoginService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
// 시큐리티로 로그인이 될때, 시큐리티가 loadUserByUsername() 실행해서 username을 체크
// 없으면 오류
// 있으면 정상적으로 시큐리티 컨텍스트 내부 세션에 로그인된 세션이 만들어진다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User userPS = userRepository.findByUsername(username).orElseThrow(
() -> new InternalAuthenticationServiceException("인증 실패"));
return new LoginUser(userPS);
}
}
- Spring Security 는 객체가 아닌 LoginUser 를 return
1-8. ResponseDto 생성
@RequiredArgsConstructor
@Getter
public class ResponseDto<T> {
private final Integer code;
private final String msg;
private final T data;
}
- 본 포스팅에서는 모든 요청의 return 을 공통 DTO 로 반환해 줄 예정
- code : -1 이면 실패, 1 이면 성공
- msg : code 값에 따른 메시지 출력
- data : 반환되는 데이터
1-9. Controller
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
private final CustomOAuth2UserService customOAuth2UserService;
//회원가입
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserReqDto.JoinReqDto joinReqDto, BindingResult bindingResult) {
UserRespDto.JoinRespDto joinRespDto = userService.join(joinReqDto);
return new ResponseEntity<>(new ResponseDto<>(1, "회원가입 성공", joinRespDto), HttpStatus.CREATED);
}
//아이디 찾기
@PostMapping("/find-username")
public ResponseEntity<?> findUsername(@RequestBody @Valid UserReqDto.FindUsernameReqDto findUsernameReqDto, BindingResult bindingResult) {
UserRespDto.FindUsernameRespDto findUsernameRespDto = userService.findUsername(findUsernameReqDto);
return new ResponseEntity<>(new ResponseDto<>(1, "아이디 찾기 성공", findUsernameRespDto), HttpStatus.OK);
}
@GetMapping("/user-name/{username}/exists")
public ResponseEntity<?> checkIdDuplicate(@PathVariable @Valid String username) {
// 1. username 중복 값 확인
if (userService.checkUsernameDuplicate(username)) {
return new ResponseEntity<>(new ResponseDto<>(-1, "이미 사용 중인 아이디 입니다.", null), HttpStatus.OK);
} else {
return new ResponseEntity<>(new ResponseDto<>(1, "사용 가능한 아이디 입니다.", null), HttpStatus.OK);
}
}
@GetMapping("/nickname/{nickname}/exists")
public ResponseEntity<?> checkNicknameDuplicate(@PathVariable @Valid String nickname) {
// 1. username 중복 값 확인
if (userService.checkNicknameDuplicate(nickname)) {
return new ResponseEntity<>(new ResponseDto<>(-1, "이미 사용 중인 닉네임입니다.", null), HttpStatus.OK);
} else {
return new ResponseEntity<>(new ResponseDto<>(1, "사용 가능한 닉네임입니다.", null), HttpStatus.OK);
}
}
@GetMapping("/user-email/{email}/exists")
public ResponseEntity<?> checkEmailDuplicate(@PathVariable @Valid String email) {
// 1. email 중복 값 확인
if (userService.checkEmailDuplicate(email)) {
return new ResponseEntity<>(new ResponseDto<>(-1, "이미 사용 중인 이메일 입니다.", null), HttpStatus.OK);
} else {
return new ResponseEntity<>(new ResponseDto<>(1, "사용 가능한 이메일 입니다.", null), HttpStatus.OK);
}
}
}
1-10. UserReqDto 생성
public class UserReqDto {
@Setter
@Getter
public static class LoginReqDto {
private String username;
private String password;
}
@Getter
@Setter
public static class JoinReqDto {
@Pattern(regexp = "^[a-zA-Z가-힣0-9]{1,20}$", message = "영문/숫자 2~20자 이내로 작성해주세요")
@NotEmpty
private String username;
@NotEmpty
@Size(min = 4, max = 20)
private String password;
@NotEmpty
@Pattern(regexp = "^[a-zA-Z0-9]{2,20}@[a-zA-Z0-9]{2,6}\\.[a-zA-Z]{2,3}$", message = "이메일 형식으로 작성해주세요")
private String email;
@NotEmpty
@Pattern(regexp = "^[a-zA-Z가-힣0-9]{1,20}$", message = "한글/영문 1~20자 이내로 작성해주세요")
private String nickname;
public User toEntity(BCryptPasswordEncoder passwordEncoder) {
return User.builder()
.username(username)
.password(passwordEncoder.encode(password))
.email(email)
.nickname(nickname)
.role(UserEnum.CUSTOMER)
.build();
}
}
@Setter
@Getter
public static class FindUsernameReqDto{
@NotEmpty
@Pattern(regexp = "^[a-zA-Z0-9]{2,10}@[a-zA-Z0-9]{2,6}\\.[a-zA-Z]{2,3}$", message = "이메일 형식으로 작성해주세요")
private String email;
}
}
1-11. UserRespDto
public class UserRespDto {
@Setter
@Getter
public static class LoginRespDto {
private Long id;
private String username;
private String role;
private String nickname;
private String createdAt;
public LoginRespDto(User user) {
this.id = user.getId();
this.role = String.valueOf(user.getRole());
this.username = user.getUsername();
this.nickname = user.getNickname();
this.createdAt = CustomDateUtil.toStringFormat(user.getCreatedAt());
this.nickname = user.getNickname();
}
}
@ToString
@Setter
@Getter
public static class JoinRespDto {
private Long id;
private String username;
private String nickname;
private String role;
public JoinRespDto(User user) {
this.id = user.getId();
this.role = String.valueOf(user.getRole());
this.username = user.getUsername();
this.nickname = user.getNickname();
}
}
@Getter
@Setter
@AllArgsConstructor
public static class FindUsernameRespDto {
private String username;
}
}
2. JWT 설정
2-1. JwtProcess 생성
public class JwtProcess {
// 토큰 생성
public static String create(LoginUser loginUser) {
String jwtToken = JWT.create()
.withSubject("bank")
.withExpiresAt(new Date(System.currentTimeMillis() + JwtVO.EXPIRATION_TIME))
.withClaim("id", loginUser.getUser().getId())
.withClaim("role", loginUser.getUser().getRole() + "")
.sign(Algorithm.HMAC512(JwtVO.SECRET));
return JwtVO.TOKEN_PREFIX + jwtToken;
}
// 토큰 검증 (return 되는 LoginUser 객체를 강제로 시큐리티 세션에 직접 주입)
public static LoginUser verify(String token) {
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(JwtVO.SECRET)).build().verify(token);
Long id = decodedJWT.getClaim("id").asLong();
String role = decodedJWT.getClaim("role").asString();
User user = User.builder().id(id).role(UserEnum.valueOf(role)).build();
LoginUser loginUser = new LoginUser(user);
return loginUser;
}
}
2-2. JwtVo 생성
public interface JwtVO {
public static final String SECRET = "abo2"; // HS256 (대칭키)
public static final int EXPIRATION_TIME = 1000 * 60 * 60 * 2; // 두시간
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER = "Authorization";
}
2-3. JwtAuthorizationFilter 생성
/*
* 모든 주소에서 동작함 (토큰 검증)
*/
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final Logger log = LoggerFactory.getLogger(getClass());
public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
// JWT 토큰 헤더를 추가하지 않아도 해당 필터는 통과는 할 수 있지만, 결국 시큐리티단에서 세션 값 검증에 실패함.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (isHeaderVerify(request, response)) {
// 토큰이 존재함
log.debug("디버그 : 토큰이 존재함");
String token = request.getHeader(JwtVO.HEADER).replace(JwtVO.TOKEN_PREFIX, "");
LoginUser loginUser = JwtProcess.verify(token);
log.debug("디버그 : 토큰이 검증이 완료됨");
// 임시 세션 (UserDetails 타입 or username)
Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
loginUser.getAuthorities()); // id, role 만 존재
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("디버그 : 임시 세션이 생성됨");
}
chain.doFilter(request, response);
}
private boolean isHeaderVerify(HttpServletRequest request, HttpServletResponse response) {
String header = request.getHeader(JwtVO.HEADER);
if (header == null || !header.startsWith(JwtVO.TOKEN_PREFIX)) {
return false;
} else {
return true;
}
}
}
2-4. CustomResponseUtil 생성
public class CustomResponseUtil {
private static final Logger log = LoggerFactory.getLogger(CustomResponseUtil.class);
public static void success(HttpServletResponse response, Object dto) {
try {
ObjectMapper om = new ObjectMapper();
ResponseDto<?> responseDto = new ResponseDto<>(1, "로그인성공", dto);
String responseBody = om.writeValueAsString(responseDto);
response.setContentType("application/json; charset=utf-8");
response.setStatus(200);
response.getWriter().println(responseBody);
} catch (Exception e) {
log.error("서버 파싱 에러");
}
}
public static void fail(HttpServletResponse response, String msg, HttpStatus httpStatus) {
try {
ObjectMapper om = new ObjectMapper();
ResponseDto<?> responseDto = new ResponseDto<>(-1, msg, null);
String responseBody = om.writeValueAsString(responseDto);
response.setContentType("application/json; charset=utf-8");
response.setStatus(httpStatus.value());
response.getWriter().println(responseBody);
} catch (Exception e) {
log.error("서버 파싱 에러");
}
}
}
2-5. JwtAuthenticationFilter
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final Logger log = LoggerFactory.getLogger(getClass());
private AuthenticationManager authenticationManager;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
setFilterProcessesUrl("/api/login");
this.authenticationManager = authenticationManager;
}
// Post : /api/v1/login
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.debug("디버그 : attemptAuthentication 호출됨");
try {
ObjectMapper om = new ObjectMapper();
UserReqDto.LoginReqDto loginReqDto = om.readValue(request.getInputStream(), UserReqDto.LoginReqDto.class);
// 강제 로그인
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginReqDto.getUsername(), loginReqDto.getPassword());
// UserDetailsService의 loadUserByUsername 호출
// JWT를 쓴다 하더라도, 컨트롤러 진입을 하면 시큐리티의 권한체크, 인증체크의 도움을 받을 수 있게 세션을 만든다.
// 이 세션의 유효기간은 request하고, response하면 끝
Authentication authentication = authenticationManager.authenticate(authenticationToken);
return authentication;
} catch (Exception e) {
// unsuccessfulAuthentication 호출함
throw new InternalAuthenticationServiceException(e.getMessage());
}
}
// 로그인 실패
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
CustomResponseUtil.fail(response, "로그인실패", HttpStatus.UNAUTHORIZED);
}
// return authentication 잘 작동하면 successfulAuthentication 메서드 호출
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
log.debug("디버그 : successfulAuthentication 호출됨");
LoginUser loginUser = (LoginUser) authResult.getPrincipal();
String jwtToken = JwtProcess.create(loginUser);
response.addHeader(JwtVO.HEADER, jwtToken);
UserRespDto.LoginRespDto loginRespDto = new UserRespDto.LoginRespDto(loginUser.getUser());
CustomResponseUtil.success(response, loginRespDto);
}
}
React 코드는 다음 포스팅에 작성하겠습니다.
'Back_End > Java' 카테고리의 다른 글
Spring Security + JWT + Redis (0) | 2024.05.16 |
---|---|
OAuth 를 이용한 구글 로그인 (0) | 2024.04.26 |
Thread 와 Collection (0) | 2024.04.22 |
상속과 인터페이스 (0) | 2024.04.22 |
@BaekSJ :: 개발자의 길
Back-End, Front-End, DevOps 기록 블로그
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!