시작에 앞서
백엔드를 시작한지 그렇게 오래되지 않은 상태로 프로젝트를 할 때, 로그인 기능을 맡아서 했는데 프로젝트가 짧은 기간이었고 지식이 얕은 관계로 시간이 너무 오래걸려서 소셜로그인(카카오) + Jwt(refresh토큰 재발행 하는 로직 없음) 정도로만 구현하고 넘어갔기 때문에 이번에 스터디를 하면서 확실하게 전부 다 구현해보자라는 생각을 했고 적용하면서 나중에 보기 편하게끔 기록해놓을 생각이다.
Spring Security 로그인을 시작으로 Jwt를 적용하고 Oauth(구글)을 적용 해볼 생각이다.
📌- Spring SecurityConfig
제일 처음 애먹은 SecurityConfig인데 버전이 계속 바뀌면서 많이 바뀌어서 이전에는 WebSecurityConfigurerAdapter를 사용했었다면 이젠 SecurityFilterChain을 직접 빈으로 등록해서 사용해야 한다.
import com.example.UsedTrade.auth.configure.auth.*;
import com.example.UsedTrade.auth.util.oauth.CustomOAuth2UserService;
import com.example.UsedTrade.auth.util.oauth.OAuth2LoginFailureHandler;
import com.example.UsedTrade.auth.util.oauth.OAuth2LoginSuccessHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtFilter jwtFilter;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 인가(접근권한) 설
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/user/*","/").permitAll()
.anyRequest().authenticated())
// Jwt와 Oauth부분은 일단 주석처리
// .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// .exceptionHandling(excep -> excep
// .accessDeniedHandler(jwtAccessDeniedHandler)
// .authenticationEntryPoint(jwtAuthenticationEntryPoint))
// .oauth2Login(oauth-> oauth
// .successHandler(oAuth2LoginSuccessHandler)
// .failureHandler(oAuth2LoginFailureHandler)
// .userInfoEndpoint(userInfo->userInfo.userService(customOAuth2UserService)))
// .build();
}
}
이런식으로 설정을 하고 requestMatchers에는 Security에 걸리지 않고 접속 할 수 있도록 엔드포인트를 설정해준다.
📌 - User
UserEntity
기본적으로 필요한 정보들로 UserEntity를 만들었다.
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
import java.util.*;
@Entity
@Getter
@Builder(toBuilder = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "users")
public class UserEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(nullable = false)
private String email;
@Column
private String password;
@Column
private String nickname;
@Column
private String imageUrl; // 프로필 이미지
@Column
private int age;
@Column
private String city; // 사는 도시
@Enumerated(EnumType.STRING)
private Role role;
@Enumerated(EnumType.STRING)
private SocialType socialType; // KAKAO, NAVER, GOOGLE
private String socialId; // 로그인한 소셜 타입의 식별자 값 (일반 로그인인 경우 null)
private String refreshToken; // 리프레시 토큰
@CreationTimestamp
private LocalDateTime createdAt;
@CreationTimestamp
private LocalDateTime updatedAt;
// 비밀번호 암호화 메소드
public void passwordEncode(PasswordEncoder passwordEncoder) {
this.password = passwordEncoder.encode(this.password);
}
public void updateRefreshToken(String updateRefreshToken) {
this.refreshToken = updateRefreshToken;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(nickname));
return authorities;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
Role
Oauth를 사용할 때 Role이 필요해서 미리 작성해놓음
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
SocialType
public enum SocialType {
KAKAO, NAVER, GOOGLE;
}
📌 - Controller
간단하게 가입과 로그인 정도만 만들어놨고, 아래의 Token 부분은 나중에 Jwt를 구현할 때 사용하므로 지금 단계에선 필요한 값만 가져와서 사용하면 된다.
import com.example.UsedTrade.auth.model.Token;
import com.example.UsedTrade.auth.model.request.JoinRequest;
import com.example.UsedTrade.auth.model.request.LoginRequest;
import com.example.UsedTrade.auth.service.UserService;
import com.example.UsedTrade.support.ApiResponse;
import com.example.UsedTrade.support.ApiResponseGenerator;
import com.example.UsedTrade.support.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor // final이 붙거나 @NotNull 이 붙은 필드의 생성자를 자동 생성해주는 롬복 어노테이션
@RequestMapping("/api/v1/user")
public class LoginController
{
private final UserService userService;
@PostMapping("/join")
public ApiResponse<ApiResponse.SuccessBody<Void>> join(@RequestBody JoinRequest request) throws Exception {
userService.join(request);
return ApiResponseGenerator.success(HttpStatus.OK, MessageCode.SUCCESS);
}
@PostMapping("/login")
public ApiResponse<ApiResponse.SuccessBody<Token>> login(@RequestBody LoginRequest request) {
Token token = userService.login(request);
return ApiResponseGenerator.success(token,HttpStatus.OK, MessageCode.SUCCESS);
}
}
📌 - Repository
DB에서 이메일과 닉네임, 토큰으로 유저를 찾을 수 있도록 Repository 설정
import com.example.UsedTrade.auth.entity.SocialType;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.UsedTrade.auth.entity.UserEntity;
import java.util.*;
public interface UserRepository extends JpaRepository<UserEntity,Long> {
Optional<UserEntity> findByEmail(String Email);
Optional<UserEntity> findByNickname(String nickname);
Optional<UserEntity> findByRefreshToken(String refreshToken);
Optional<UserEntity> findBySocialTypeAndSocialId(SocialType socialType, String socialId);
}
📌 - Service
import com.example.UsedTrade.auth.configure.auth.TokenProvider;
import com.example.UsedTrade.auth.entity.Role;
import com.example.UsedTrade.auth.entity.UserEntity;
import com.example.UsedTrade.auth.model.Token;
import com.example.UsedTrade.auth.model.request.JoinRequest;
import com.example.UsedTrade.auth.model.request.LoginRequest;
import com.example.UsedTrade.auth.repository.UserRepository;
import com.example.UsedTrade.auth.util.UserConverter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
@Service
public class UserService {
private final RedisTemplate<String, String> redisTemplate;
private final UserRepository userRepository;
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final PasswordEncoder passwordEncoder;
@Value("${spring.auth.jwt.refresh_time}")
private Long REFRESH_TOKEN_EXPIRE_TIME;
public void join(JoinRequest request) throws Exception {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new Exception("이미 존재하는 이메일입니다.");
}
if (userRepository.findByNickname(request.getNickname()).isPresent()) {
throw new Exception("이미 존재하는 닉네임입니다.");
}
UserEntity user = UserEntity.builder()
.email(request.getEmail())
.password(request.getPassword())
.nickname(request.getNickname())
.age(request.getAge())
.city(request.getCity())
.role(Role.USER)
.build();
user.passwordEncode(passwordEncoder);
userRepository.save(user);
}
public Token login(LoginRequest request) {
UserEntity entity =userRepository.findByEmail(request.getEmail()).orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(request.getEmail(),request.getPassword());
String username = authenticationToken.getName();
Token token = tokenProvider.generateToken(entity);
redisTemplate.opsForValue().set(
username,
token.getRefreshToken(),
REFRESH_TOKEN_EXPIRE_TIME,
TimeUnit.MILLISECONDS
);
return token;
}
public String redisTest(String token){
Authentication authentication = tokenProvider.getAuthentication(token);
String refreshToken = (String) redisTemplate.opsForValue().get(authentication.getName());
return refreshToken;
}
}
join - 가입할 때 DB에서 해당 이메일이나 닉네임이 존재하는지 여부를 확인 후에 없으면 DB에 저장하도록 함
login - DB에서 이메일로 해당 유저가 있는지 확인하고 해당 유저의 토큰을 반환 위의 코드에서는 refreshToken을 Redis에 넣어서 관리하려고 redisTemplate를 사용했는데 Redis를 사용하지 않는다면 지워도 되는 부분이다.
redisTest - 토큰이 redis에 잘 저장됐는지 확인 용도
📌 - Request
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class JoinRequest {
private String email;
private String password;
private String nickname;
private int age;
private String city;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class LoginRequest {
private String email;
private String password;
}
전체 코드는
https://github.com/wnstn819/UserTrade
GitHub - wnstn819/UserTrade: 중고거래 서비스 구현
중고거래 서비스 구현. Contribute to wnstn819/UserTrade development by creating an account on GitHub.
github.com
여기서 확인 할 수 있다.
Reference
https://ksh-coding.tistory.com/57
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login(구글, 네이버, 카카오) API 구현 (1) - 회원(User) 관
들어가기 전 처음 프로젝트 진행 시, 아무것도 모르던 상태에서 처음으로 만들어야겠다고 생각이 든 기능이 로그인 기능이었습니다. 처음부터 자체 Login과 OAuth2 Login(소셜 로그인)을 같이 구현해
ksh-coding.tistory.com
거의 대부분의 코드를 이 블로그에서 많이 도움을 받았고 설명도 되게 잘 되어있어서 처음 로그인 만들때 참고를 하면 좋을 것 같다고 생각한다.