프론트엔드, 백엔드를 둘 다 다뤄본 경험 상 우선 로그인 API가 돼야 도미노처럼 다른 기능들을 수월하게 구현할 수 있었다.
그래서 회원가입 및 로그인 API를 먼저 구현했다.
시큐리티랑 jwt 너무 헷갈려서 고생 많이 했다...
보통 시큐리티 + jwt 와 시큐리티 + session 방식이 있음. 전자가 더 많이 사용됨
인증 : 로그인 정보와 일치한가? 인가 : 접근할 권한이 있는가?
만들다보니 생각보다 많아졌다;;
일단 패키지 다 만들어 놓고 base와 user부분만 구현함.
base는 BaseResponse가 담겨있음.
기본적으로 Entity-Dto-Repository-Service-Controller의 구조를 가진다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Entity
public class User extends BaseTimeEntity implements UserDetails {
@Id @Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(nullable = false,length = 45, unique = true)
private String email;
@Column(nullable = false,length = 45)
private String nickname;
@NotNull
private String password;
private String profileImage;
@Column(name = "user_type")
@Enumerated(EnumType.STRING)
private UserType userType;
private String refreshToken;
@Enumerated(EnumType.STRING)
private Role role;
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public void addUserAuthority() {
this.role = Role.ROLE_USER;
}
public void encodePassword(PasswordEncoder passwordEncoder){
this.password = passwordEncoder.encode(password);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<GrantedAuthority> auth = new ArrayList<>();
auth.add(new SimpleGrantedAuthority(role.name()));
return auth;
}
@Override
public String getUsername() {
return this.email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Getter
@ToString
@NoArgsConstructor
public class EmailLoginRequestDto {
@Email
private String email;
@NotNull
private String password;
}
@Data // getter/setter, requiredArgsController, ToString 등 합쳐놓은 세트
@Builder
@AllArgsConstructor
public class EmailRequestDto {
@NotEmpty(message = "이메일을 입력해주세요")
@Email
private String email;
@NotEmpty(message = "비밀번호를 입력해주세요")
@Pattern(regexp = " ^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d~!@#$%^&*()+|=]{8,20}$",
message = "8자 이상이며 최대 20자까지 허용. 반드시 숫자, 문자 포함")
private String password;
@NotEmpty(message = "닉네임을 입력해주세요")
@Size(min=2, message = "닉네임은 최소 두 글자 이상입니다")
private String nickname;
private String profileImage;
@Builder
public User toEntity(){
return User.builder()
.email(email)
.nickname(nickname)
.password(password)
.role(Role.ROLE_USER)
.userType(UserType.EMAIL)
.build();
}
}
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByEmail(String email);
}
public enum Role {
ROLE_USER,ROLE_ADMIN;
}
어휴.. 에러도 많이 겪고 몇 일을 여기에 매달렸다.
SecurityConfig 구현 과정에서 고민이 많았다.
로그인을 formLogin을 통해서 처리하게 된다면 타임리프를 이용하여 로그인 페이지를 구현해줘야한다. 처음에는 formLogin으로 설정하고 타임리프를 막 찾아보면서 프론트를 구현하려고 했는데 이게 더 시간이 오래걸릴거 같아서 때려침..
실제 프로젝트나 현업에서도 타임리프는 잘 사용하지 않고 프론트는 리액트 등을 이용하여 따로 구현하는 방식이 훨씬 많다고 한다. 본인도 리액트가 익숙하기 때문에 그냥 타임리프를 쓰지 않고 리액트로 빠르게 프론트를 구현하기로 했다.
타임리프가 특별히 api 호출할 필요없이 바로 변수랑 mapping이 가능해서 간편하긴한데 이것도 처음부터 새로 배우면서 익숙해지려고 하니깐 머리가 아팠다...
따라서 SecurityConfig에서 formLogin을 사용하지 않기로 했다.
구현하기 힘들었던 부분은 스프링부트 최신버전(3.2.0)을 사용하면서 새로운 규칙으로 설정해야되는 것이다.
검색했을 때 대부분의 블로그 글들은 예전 방식으로 설정하는 방법으로 올라와있어서 하나하나 비교하면서 바꾸느라 좀 애먹었다..
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception {
// 스프링부트 3.1.x~ 시큐리티 설정 방식이 변경됨. .and()를 사용하지 않음
http.httpBasic(HttpBasicConfigurer::disable);
http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement(configurer-> // 세션 사용안해서 STATELESS 상태로 설정
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize->
authorize
.requestMatchers("/user/login/email","/user/join/email").permitAll()
.requestMatchers("/user/profile/**","/user/test").hasRole("USER")
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
혹시 저처럼 헤매시는 분들은 이걸 참고하시길..
예전처럼 .and()를 사용하지 않아서 더 깔끔해진 거 같다.
@Slf4j
@Service
public class SecurityUtil {
//SecurityContext에서 Authentication객체를 꺼내와서 이 객체를 통해 로그인한 username을 리턴해주는 간단한 유틸성 메소드
public static String getLoginUsername(){
UserDetails user = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return user.getUsername();
}
}
유틸성 메소드를 정의하는 SecurityUtil 클래스이다. getLoginUsername()을 통해 현재 로그인한 사용자의 정보를 알 수 있다.
JwtTokenProvider
처음에 정한 secret-key와 만료시간을 이용해서 jwtToken을 생성하는 함수와 jwtToken의 유효성을 검증하는 함수를 정의하는 클래스이다. resolveToken() 함수는 클라이언트의 request로부터 헤더에서 클라이언트가 넣은 accessToken을 꺼내며 parseJwt() 함수는 token을 받아서 파싱하는 함수이다.
public String createToken(String userPk, List<String> roles) {
// 권한 가져오기
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key/value 쌍으로 저장됩니다.
Date now = new Date();
// Access Token 생성
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return accessToken;
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
Claims claims = parseJwt(token);
String s=claims.getSubject();
UserDetails userDetails = customUserDetailService.loadUserByUsername(s);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
Claims claims = null;
try {
claims = parseJwt(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken)) {
return bearerToken;
}
return null;
}
public Claims parseJwt(String jwt) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
JwtAuthenticationFilter
토큰을 검증하고 SecurityContextHolder에 권한을 설정하는 필터이다.
또한 다음 필터를 실행시킨다.
정말 수 많은 에러를 고쳤기 때문에 몇 개 공유해봐여..
이 에러는 UserContoller 클래스에 어노테이션으로 @Controller를 설정해놓고 @ResponseBody를 쓰지 않아서 발생했다...json형태로 data를 반환하실 분들은 @RestController를 붙여주거나 아니면 @Controller로 설정하고 각 api 함수마다 @ResponseBody를 붙여주세요.
@RestController는 @Controller에 @ResponseBody가 추가된 것으로 생각하면 된다. 이는 REST API를 개발하는데 적합하며 @Controller는 주로 view를 반환하는데 사용되기 때문에 따로 @ResposneBody를 붙여야한다.
[참고] https://mangkyu.tistory.com/49
이건 User의 Role을 설정하는 부분에서 실수로 인해 발생했다.
나는 처음에 User의 Role을 위의 코드처럼 ENUM 클래스로 구현하고 USER , ADMIN으로 구분했다. 그리고 SecurityConfig에서 .hasRole("USER")을 붙여서 USER라는 Role을 가진 사용자의 접근을 허용하도록 했다. 같은 USER이므로 인식할 줄 알았는데...
SecurityConfig는 USER라고 명시해도 DB에는 ROLE_USER라고 저장되어야한다고 한다...
이렇게 어찌저찌 우역곡절 끝에 이메일 회원가입 로그인을 완성했다. 이제 다음에는 RefreshToken 재발급을 구현하고, 차차 로그아웃 및 유저 정보 수정같은 CRUD API를 구현해야될 거 같다... 카카오 로그인은 좀 나중에 미루기로 ㅎㅎ^^