메인 프로젝트를 하면서 REST API로 로그인을 구현해보았다.
해당 부분을 기억하기 위해 이 글을 작성하였다.
이 포스팅은 프로젝트 과정에서 흐름을 기억하기 위해 작성한 것으로, 기본 흐름의 틀정도라고 생각하면 좋을 듯 하다.
해당 포스팅의 코드는 아래의 깃헙 주소에서도 확인 가능.
✨ 들어가기 전에, 메인 프로젝트에서 로그인 구현의 흐름 ✨
: 아래의 흐름을 바탕으로 + Redis를 이용하여 구현
1️⃣ build.gradle 설정
- JWT 토큰을 이용하여 로그인을 구현할 예정이므로 jjwt 의존성도 추가하였음.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-validation'
/* lombok > mapstruct */
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
/* Security */
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
/* DB */
implementation 'com.h2database:h2'
/* jwt */
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
/* Redis */
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
2️⃣ application.yml 설정
- 토큰 발급을 위해 secret key 환경변수 설정
- 데이터 베이스는 인메모리인 H2 사용
spring:
h2:
console:
enabled: true
path: /h2
datasource:
url: jdbc:h2:mem:test
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
cache:
type: redis
redis:
host: localhost
port: 6379
jwt:
secret-key: ${JWT_SECRET_KEY}
3️⃣ LoginRequestDto 구현
- 로그인 시 들어올 요청 DTO 구현
- 이후 토큰 발급에 도움을 줄 메서드 구현
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginRequestDto {
private String email;
private String password;
public UsernamePasswordAuthenticationToken toAuthentication() {
return new UsernamePasswordAuthenticationToken(email, password);
}
}
4️⃣ JwtProvider 클래스 구현
- Access Token과 Refresh Token 발급을 위한 로직 작성
- 토큰 복호화 및 유효성 검증 로직 확인
@Slf4j
@Component
public class JwtProvider{
private final Key key;
private final CustomAuthorityUtils authorityUtils;
private final MemberRepository memberRepository;
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 6;
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;
private static final String AUTHORITIES_KEY = "role";
private static final String BEARER_TYPE = "Bearer ";
/* 시크릿 키 */
public JwtProvider(@Value("${jwt.secret-key}") String secretKey, CustomAuthorityUtils authorityUtils,
MemberRepository memberRepository) {
this.authorityUtils = authorityUtils;
this.memberRepository = memberRepository;
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generatedTokenDto(String username) {
/* 🐥 권한 가져오기 */
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다."));
String authorities = member.getRole();
long now = (new Date()).getTime();
Date accessTokenExpiration = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
Date refreshTokenExpiration = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
/* 🐹 Access Token 생성 */
String accessToken = createAccessToken(username, authorities, accessTokenExpiration);
/* 🦊 Refresh Token 생성 */
String refreshToken = createRefreshToken(username, refreshTokenExpiration);
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiration.getTime())
.refreshToken(refreshToken)
.refreshTokenExpiresIn(refreshTokenExpiration.getTime())
.build();
}
/* 🐹 Access Token 생성 */
public String createAccessToken(String username, String role, Date expiration) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("role", role);
String accessToken =
Jwts.builder()
.setSubject(username)
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(expiration)
.compact();
return accessToken;
}
/* 🦊 Refresh Token 생성 */
public String createRefreshToken(String username, Date expiration) {
return Jwts.builder()
.setSubject(username)
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(expiration)
.compact();
}
public Authentication getAuthentication(String accessToken) {
/* 토큰 복호화 */
Map<String, Object> claims = parseClaims(accessToken);
/* 만약 복호화 한 토큰 안에 권한이 없으면 예외 던지기 */
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한이 존재하지 않습니다.");
}
/* 🐥 권한이 있다면, 권한 가져오기 */
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((String)claims.get(AUTHORITIES_KEY));
/* UserDetails 객체를 만들어 Authentication 리턴 */
UserDetails principal = new User((String)claims.get("username"), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
/* 토큰 존재 여부 판별 */
public boolean validate(String token) {
return this.getTokenClaims(token) != null;
}
public Claims getTokenClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (SecurityException exception) {
log.info("유효하지 않은 JWT 토큰 서명입니다.");
} catch (MalformedJwtException exception) {
log.info("유효하지 않은 JWT 토큰입니다.");
} catch (ExpiredJwtException exception) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException exception) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException exception) {
log.info("무언가 잘못되었습니다.");
}
return null;
}
/* 토큰 복호화 */
public Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key).build()
.parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException exception) {
log.info("이미 만료된 토큰입니다.");
return exception.getClaims();
}
}
public long getAccessTokenTime() {
return ACCESS_TOKEN_EXPIRE_TIME;
}
public long getRefreshTokenTime() {
return REFRESH_TOKEN_EXPIRE_TIME;
}
}
5️⃣ RedisConfig 클래스 구현
- RefreshToken을 Redis에 저장하기 위해 RedisConfig 생성
- RedisTemplate 방식을 이용하여 구현
@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer()); /* redis 에서 데이터를 볼 수 있도록 */
redisTemplate.setValueSerializer(new StringRedisSerializer()); /* redis 에서 데이터를 볼 수 있도록 */
return redisTemplate;
}
}
6️⃣ MemberController 클래스 구현
- 회원 가입 로직을 같이 구현한 이유?
- 메인 프로젝트 때에 회원 가입을 하면 자동 로그인 하도록 하였기 때문
@Slf4j
@RestController
@RequiredArgsConstructor
@Transactional
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
/* 회원 가입 */
@PostMapping("/members/signup")
public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) throws Exception {
Member createMember = memberService.createMember(mapper.memberPostDtoToMember(requestBody));
/* 로그인 시도를 위한 LoginRequestDto 생성 */
LoginRequestDto loginRequestDto = new LoginRequestDto(createMember.getEmail(), requestBody.getPassword());
/* 자동 로그인 */
return loginMember(loginRequestDto);
}
/* 로그인 */
@PostMapping("/members/login")
public ResponseEntity loginMember(@RequestBody LoginRequestDto requestBody) {
/* 로그인 정보로 토큰 생성 */
TokenDto tokenDto = memberService.tokenLogin(requestBody);
/* 엑세스 토큰 헤더에 담아주기 */
HttpHeaders httpHeaders = setHeader(tokenDto.getAccessToken());
Member member = memberService.findVerifiedMemberByEmail(requestBody.getEmail());
/* 역할에 따라 응답 바디가 다르므로, 나누어 주었다.*/
if(member.getRole().equals("SELLER")) {
return new ResponseEntity<>(mapper.memberToSellerResponseDto(member), httpHeaders, HttpStatus.OK);
} else if (member.getRole().equals("CLIENT")) {
return new ResponseEntity<>(mapper.memberToClientResponseDto(member), httpHeaders, HttpStatus.OK);
}
/* 로그인이 실패할 경우, 문제가 존재하는 것 */
throw new RuntimeException("로그인에 실패하였습니다.");
}
/* 로그아웃 */
@GetMapping("/members/logout")
public ResponseEntity logoutMember(HttpServletRequest request) {
memberService.logoutMember(request);
return ResponseEntity.ok("로그아웃 되었습니다.");
}
/* 로그인 헤더 설정 */
private HttpHeaders setHeader(String token) {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set("Authorization", token);
return httpHeaders;
}
}
7️⃣ MemberService 클래스 구현
- 회원 가입 및 로그인 로직 구현
- 로그인 시, Redis에 리프레시 토큰 저장
- 로그아웃 시, Redis에 리프레시 토큰 삭제
- 그 외의 검사가 필요한 메서드들 구현
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final CustomAuthorityUtils authorityUtils;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtProvider jwtProvider;
private final RedisTemplate<String, Object> redisTemplate;
/* 회원 가입 */
@Transactional
public Member createMember(Member member) {
verifyEmailExist(member.getEmail());
correctRole(member.getRole());
List<String> roles = authorityUtils.createRoles(member.getRole());
if (member.getRole().equalsIgnoreCase("client")) {
member.setClient(new Client());
}
if (member.getRole().equalsIgnoreCase("seller")) {
member.setSeller(new Seller());
}
/* 비밀번호 암호화 + 대문자로 저장 */
String encryptedPassword = passwordEncoder.encode(member.getPassword());
member.setCreateMember(
encryptedPassword,
member.getRole().toUpperCase(),
roles,
ProviderType.LOCAL
);
return memberRepository.save(member);
}
/* 로그인 */
public TokenDto tokenLogin(LoginRequestDto loginRequestDto) {
/* 존재하는 회원인지 확인 */
Member member = findVerifiedMemberByEmail(loginRequestDto.getEmail());
/* 비밀번호가 일치하는지 확인 */
verifyPassword(member, loginRequestDto.getPassword());
/* 프로바이더 확인 */
checkLocalMember(member);
/* 로그인 기반으로 "Authentication 토큰" 생성 */
UsernamePasswordAuthenticationToken authenticationToken = loginRequestDto.toAuthentication();
/* AuthenticationToken 으로 인증 정보 가져오기 */
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
TokenDto tokenDto = jwtProvider.generatedTokenDto(authentication.getName());
/* Refresh Token 저장 */
redisTemplate.opsForValue()
.set("RefreshToken:" + authentication.getName(), tokenDto.getRefreshToken(),
tokenDto.getRefreshTokenExpiresIn() - new Date().getTime(), TimeUnit.MICROSECONDS);
return tokenDto;
}
/* 로그아웃 */
public void logoutMember(HttpServletRequest request) {
String accessToken = request.getHeader("Authorization").replace("Bearer ", "");
/* Access Token 검증 */
if (!jwtProvider.validate(accessToken)) {
log.error("유효하지 않은 access token");
throw new RuntimeException("유효하지 않은 access token");
}
/* 인증 정보 가져오기 */
Authentication authentication = jwtProvider.getAuthentication(accessToken);
/* 현재 유저가 맞는지 확인 */
String email = SecurityUtil.getCurrentEmail();
if (!authentication.getName().equals(email)) {
throw new RuntimeException("로그인한 사용자가 일치하지 않습니다.");
}
/* Redis 에서 RefreshToken 삭제 */
redisTemplate.delete("RefreshToken:" + authentication.getName());
}
/* 존재하는 이메일인지 확인 */
public void verifyEmailExist(String email) {
Optional<Member> optionalMember = memberRepository.findByEmail(email);
if (optionalMember.isPresent()) {
throw new RuntimeException("존재하는 이메일입니다.");
}
}
/* 이메일로 존재하는 회원인지 확인 */
public Member findVerifiedMemberByEmail(String email) {
Member findMember = memberRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("회원을 찾을 수 없습니다."));
return findMember;
}
/* 존재하는 회원인지 확인 */
private Member findVerifiedMember(long memberId) {
Optional<Member> optionalMember = memberRepository.findById(memberId);
Member findMember = optionalMember.orElseThrow(
() -> new RuntimeException("회원을 찾을 수 없습니다."));
return findMember;
}
/* 역할 확인 */
private void correctRole(String target) {
if (! target.equalsIgnoreCase("CLIENT") && ! target.equalsIgnoreCase("SELLER")) {
throw new RuntimeException("역할이 잘못 입력되었습니다.");
}
}
/* 회원가입 시 비밀번호 확인 */
public void correctPassword(String password, String passwordCheck) {
if (!password.equals(passwordCheck)) {
throw new RuntimeException("비밀번호가 일치하지 않습니다.");
}
}
/* 로그인 시 비밀번호 확인 */
private void verifyPassword(Member member, String password){
if (!passwordEncoder.matches(password, member.getPassword())) {
throw new RuntimeException("올바르지 않은 비밀번호 입니다.");
}
}
/* 로컬 회원인지 확인 */
private void checkLocalMember(Member member) {
if (member.getProviderType() != ProviderType.LOCAL) {
throw new RuntimeException("소셜 회원입니다.");
}
}
}
8️⃣ MemberPrincipalService 클래스와 MemberPrincipal 클래스 구현
- 현재 로그인 한 유저의 정보를 저장
- 해당 클래스가 없으면 MemberService에서 인증 정보를 가져오지 못함
@Service
@RequiredArgsConstructor
public class MemberPrincipalService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new RuntimeException("회원이 존재하지 않습니다."));
return MemberPrincipal.create(member);
}
}
@Getter
@Setter
@AllArgsConstructor
@RequiredArgsConstructor
public class MemberPrincipal implements UserDetails, OAuth2User, OidcUser {
/* username == email */
private final String username;
private final String password;
private final ProviderType providerType;
private final String role;
private final Collection<GrantedAuthority> authorities;
private Map<String, Object> attributes;
public static MemberPrincipal create(Member member) {
return new MemberPrincipal(
member.getEmail(),
member.getPassword(),
member.getProviderType(),
member.getRole(),
Collections.singletonList(new SimpleGrantedAuthority(member.getRoles().toString()))
);
}
public static MemberPrincipal create(Member member, Map<String, Object> attributes) {
MemberPrincipal memberPrincipal = create(member);
memberPrincipal.setAttributes(attributes);
return memberPrincipal;
}
@Override
public String getName() {
return username;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@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 Map<String, Object> getClaims() {
return null;
}
@Override
public OidcUserInfo getUserInfo() {
return null;
}
@Override
public OidcIdToken getIdToken() {
return null;
}
}
9️⃣ SecurityConfig 설정
- PasswordEncoder를 Bean으로 등록해야 함
- JWT로 로그인을 구현하기 때문에 폼 로그인은 사용하지 않음
@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin() /* h2 사용을 위해 */
.and()
.csrf().disable()
.cors()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.apply(new JwtSecurityConfig(jwtProvider))
.and()
.authorizeHttpRequests(authorize -> authorize
/* 메인 페이지는 모두 접근이 가능해야한다. */
.antMatchers(HttpMethod.GET, "/").permitAll() /* 메인 페이지 */
/* 회원 관련 접근 제한 */
.antMatchers(HttpMethod.POST, "/members/signup").permitAll() /* 자체 회원가입 */
.antMatchers(HttpMethod.POST, "/login").permitAll() /* 자체 로그인 */
.antMatchers(HttpMethod.GET, "/login/**").permitAll() /* 소셜 로그인을 위해 */
.antMatchers(HttpMethod.POST, "/login/**").permitAll() /* 소셜 로그인을 위해 */
.anyRequest().authenticated()
)
.exceptionHandling()
.authenticationEntryPoint(new MemberAuthenticationEntryPoint())
.accessDeniedHandler(new MemberAccessDeniedHandler());
return http.build();
}
}
🔟 그 외에 필요한 핸들러와 필터 구현
- SecurityFilter : OncePerRequestFilter를 상속받아 구현
- MemberAccessDeniedHandler : 접근 할 수 없는 권한을 가진 경우, 접근 권한 제한 핸들러
- MemberAuthenticationEntryPointHandler : 유효하지 않은 인증이거나, 인증 정보가 부족할 때
- JwtSecurityConfig : SecurityFilter를 추가
- WebMvcConfig : CORS 설정을 위한 config
@RequiredArgsConstructor
public class SecurityFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private static final String BEARER_TYPE = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
/* Header 에서 토큰 꺼내기 ->BEARER_TYPE 이 지워진 채 리턴*/
String token = resolvedToken(request);
/* 토큰이 공백 제외 1글자 이상이고, 유효성 검사를 통과하면 조건문 실행 */
if (StringUtils.hasText(token) && jwtProvider.validate(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
/* 필터 체인 전달 요청 */
filterChain.doFilter(request, response);
}
private String resolvedToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(BEARER_TYPE.length());
}
return null;
}
}
/* 접근에 필요한 권한 없이 접근 -> 403 forbidden 에러 */
@Slf4j
@Component
public class MemberAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.warn("403 Forbidden error happened: {}", accessDeniedException.getMessage());
log.error("접근 권한 없음 에러 : {}", accessDeniedException.getMessage());
}
}
/* 유효하지 않은 인증이거나 인증 정보가 부족 -> 401 Unauthorized 에러 */
@Slf4j
@Component
public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.warn("401 Unauthorized error happened: {}", authException.getMessage());
log.error("유효하지 않은 인증 혹은 인증 정보 없음 에러 : {}", authException.getMessage());
}
}
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final JwtProvider jwtProvider;
@Override
public void configure(HttpSecurity http) {
SecurityFilter customFilter = new SecurityFilter(jwtProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedMethods("POST", "PUT", "GET", "DELETE", "OPTIONS", "PATCH") /* 요청 가능한 메서드 */
.allowedHeaders("*") /* 헤더 허용 */
.exposedHeaders("Authorization", "Refresh") /* 헤더를 통하여 토큰을 전달해야 하기 때문, 추가 헤더 허용 */
.allowedOriginPatterns("*")
.allowCredentials(true); /* 쿠키 요청 허용 */
}
}
'Spring' 카테고리의 다른 글
[Spring] 로그인 구현 번외 - 소셜 로그인 시 권한 부여 (0) | 2022.12.20 |
---|---|
[Spring] 로그인 구현 3 - 카카오 로그인 구현 (1) | 2022.12.20 |
[Spring] 로그인 구현 1 - 회원 엔티티, Mapper, Repository 구현 (0) | 2022.12.20 |
[Spring] Transaction (0) | 2022.12.19 |
[Spring] JDBC와 Spring JDBC, Spring Data JDBC, Spring Data JPA (0) | 2022.12.17 |
댓글