티스토리 뷰

스프링 부트 REST API WEB 프로젝트

깃헙 링크

https://github.com/choiwoonsik/springboot_RestApi_App_Project/tree/main/restApiSpringBootApp

수행 목록

  1. 환경구성 및 helloworld 출력
  2. H2 DB 연동
  3. Swagger API 문서 연동
  4. REST API 설계
  5. RestControllerAdvice를 이용한 통합 예외 처리
  6. Entity - DTO 분리
  7. MessageSource를 이용해 예외 메시지 다국화
  8. JPA Aduting을 이용해 객체 생성시간/수정시간 적용
  9. 스프링 시큐리티 + Jwt를 이용해서 인증 및 권한 체크
  10. 스프링 시큐리티 AuthenticationEntryPoint, AccessDenied로 인증 및 인가 예외처리
  11. Jwt AccessToken + RefreshToken으로 보안성과 사용자 편의성 고도화하기
  12. JUnit Test (단위 테스트)
  13. JUnit Test (통합 테스트)
  14. OAuth 2.0 정리
  15. OAuth 2.0 카카오 로그인 part.1 Authorization code + Token 발급
  16. OAuth 2.0 카카오 로그인 part.2 토큰으로 회원 가입 / 로그인
  17. OAuth 2.0 카카오 로그인 테스트 검증
  18. 환경별 설정을 위해서profile 분리하기 

 


스프링 시큐리티란 (Spring Security)

스프링 security는 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가)를 담당하는 스프링 하위 프레임워크이다.

  • 인증 : Authenticate (누구인가?)
  • 인가 : Authorize (뭘 할 수 있는가?)
  • 두 보안을 담당한다.

스프링 시큐리티를 사용하지 않으면

  1. 매 요청마다 세션을 검사해야한다. (Authenticate)
  2. 매 요청마다 유저의 권한을 검사해야한다. (Authorize)

스프링 시큐리티는 Filter(필터)를 사용해서 이 부분을 해결한다.

 

기본 용어 설명

접근주체 (principal)

  • 보호된 리소스에 접근하는 대상

인증 (Authentication)

  • 보호된 리소스에 접근한 대상에 대해 누구인지, 애플리케이션의 작업을 수행해도 되는 주체인지 확인하는 과정
  • Form기반의 로그인, Oauth 2.0 기반 로그인
  • Who?

인가 (Authorize)

  • 해당 리소스에 대한 접근 가능한 권한을 가지고 있는지 확인하는 과정
  • 인증 (Authentication)이 이뤄진 후에 확인한다
  • Can?

권한

  • 어떠한 리소스에 대한 접근 제한, 모든 리소스는 접근 제어 권한이 걸려있다.
  • 인가 과정에서 해당 리소스에 대한 제한된 최소한의 권한을 가졌는지 확인한다.

스프링 시큐리티 특징과 구조

  • 보안과 관련해서 체계적으로 많은 옵션을 제공한다.
  • filter기반으로 동작하여 MVC와 분리하여 관리 및 동작한다.
  • 애노테이션을 통한 간단한 설정이 가능하다
  • 스프링 시큐리티는 세션 & 쿠키 방식으로 인증한다

  • 인증관리자와 접근결정 관리자를 통해서 사용자의 리소스 접근을 관리한다
    • 인증관리자 : UsenamePasswordAuthenticationFilter
    • 접근결정 관리자 : FilterSecurityInterceptor

 

스프링 시큐리티 작동 구조

스프링 시큐리티 사용시 스프링은 DispatcherServlet 앞단에 Filter를 배치시켜서 요청을 가로챈다. 클라이언트에 접근 권한이 없다면 인증화면으로 자동 리다이렉트 시킨다.

 

스프링 시큐리티 필터

SpringSecurity는 기능별 필터의 집합으로 되어있고 그 필터의 종류도 매우다양하지만, 중요한 것은 처리 순서에 있다.

클라이언트가 리소스를 요청할 때 접근 권한이 없는 경우 기본적으로 로그인 폼으로 보내게 되는데 그 역활을 하는 Filter가 UsernamePasswordAuthenticationFilter이다.

 

RestAPI 구조에서는 로그인 폼이 따로 존재하지 않으므로 인증권한이 없다는 오류를 JSON 형태로 반환해줘야 한다. 따라서 UsernamePasswordAuthenticationFilter가 작동하기 전에 해당 처리를 해줘야 한다.


Json Web Token을 통해 API 인증 및 권한 부여

  • 인증을 위한 Login, Signup API를 구현
  • 가입시 제한된 리소스에만 접근 가능하도록 ROLE_USER 권한을 회원에게 부여
  • 스프링 시큐리티에서 접근제한이 필요한 리소스에 대해서 ROLE_USER 권한을 가져야 접근이 가능하도록 한다.
  • 권한을 가진 회원이 로그인 성공시에는 리소스에 접근할 수 있는 Jwt 토큰을 발급해준다.
  • Jwt 보안 토큰으로 회원은 권한이 필요한 Api 리소스를 요청해서 사용한다.

JWT : Json Web Token

사용 예시 : 서버는 "관리자로 로그인됨"이라는 클레임이 있는 토큰을 생성하여 이를 클라이언트에 제공할 수 있다. 그러면 클라이언트는 해당 토큰을 사용하여 관리자로 로그인됨을 증명한다.
  • 전자 서명 된 URL-safe(URL로 이용할 수있는 문자로만 구성된)의 JSON
  • 토큰 자체에 데이터가 담겨있어서 클라이언트는 Jwt으로 서버에 권한이 필요한 리소스를 요청하고, api 서버는 Jwt이 유효한지 여부와, Jwt에 담긴 유저 정보를 확인해서 리소스를 제공할 수 있다.
  • JWS + JWE 기술이 들어가있다. Json Web Signature (서명) + Encryption (암호화)

http://www.opennaru.com/opennaru-blog/jwt-json-web-token/

서버는 클라이언트의 로그인 정보를 저장하지 않고 토큰기반의 인증 메커니즘으로 작동되므로 무상태로 작동된다.

 

토큰 전략 (Access Token + Refresh Token)

  • 토큰은 일반적으로 편의성과 보안성을 위해 Access TokenRefresh Token 두개를 갖게한다.
  • Access Token의 expire 시간을 짧게, Refresh Token의 expire시간을 길게 잡으면 사용자의 편의와 보안을 높일 수 있다.
    1. Access Token을 전송했을 때 만료시간으로 인해 실패한다면 Refresh Token을 확인한다.
    2. Refresh token이 일치하고 만료되지 않았다면 Access Token을 자동발급해서 새로운 만료시간을 갖게해준다
      (사용자는 로그아웃 당하지 않음)
    3. Access Token이 탈취당해도 만료시간이 짧으므로 보안성이 좋다. 하지만 Refresh Token이 탈취당하면 더 위험할 수 있는 문제가 있다
그렇다면 Refresh Token의 보안이 중요한데 어디에 저장하는게 좋을까?

  • Local Storage : 자바스크립트로 접근이 매우쉽다. 따라서 공격에 취약하다
  • Cookie : HTTP Only, Sercure 옵션을 사용하면 어느정도 보안이된다. 그래도 혹시 모른다...
  • Server Side -> 결국은 서버로 돌아가게 된다. 서버의 무상태를 위한 Jwt이지만 보안을 위해 서버에 저장하게 되는 재밌는 상황이다.
    • 그래서 DB에 저장한다. DB에 실제 Refresh Token값을 저장하고 이 토큰이 저장된 DB의 Index값을 로컬스토리지나 쿠키에 저장하게 된다. 해당 쿠키의 만료시간을 1년으로 잡아버리면 항상 로그인 상태로 유지할 수도 있다.
    • 인덱스 값이 탈취당해도 단순 숫자에 불과하므로 DB가 해킹당하지 않는이상 보안성이 매우 우수하다.
    • 인덱스값을 Hash처리해서 보관하면 더욱 우수한 보안을 가질 수 있다.
    • 그러면 DB는 리프레쉬 토큰을 보관하는 곳을 HashMap, HashTable로 구현하게 된다.

스프링 시큐리티 사용을 위한 의존성 추가

// spring security 의존성
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

1. JwtProvider 생성

Json Web Token 생성 및 유효성 검증을 하는 컴포넌트이다. Jwts는 여러가지 암호화 알고리즘을 제공하고 알고리즘과 비밀키를 가지고 토큰을 생성하게 된다. (SignatureAlgorithm.XXXX + secretKey)

 

이때 Claim 정보에는 토큰에 부가적으로 실어 보낼 정보를 담을 수 있다. claim 정보에 회원을 구분할 수 있는 값을 세팅하고 토큰이 들어오면 해당 값으로 회원을 구분해서 리소스를 제공하면 된다.

 

Jwt에는 expire시간을 정해서 해당 토큰의 만료시간을 정해줄 수 있다. 토큰 발급 후 일정한 시간이 지나면 해당 토큰을 만료 시킨다.

 

resolveToken 메서드는 Http Request header에서 세팅된 토큰값을 가져와서 유효성 검사를 한다. 제한된 리소스에 접근할 때 Http Header에 토큰을 세팅하여 호출하면 유효성 검사를 통해 사용자 인증을 받을 수 있다.

JwtProvider
package com.restApi.restApiSpringBootApp.config.security;

// import ...

@RequiredArgsConstructor
@Component
public class JwtProvider {

    @Value("spring.jwt.secret")
    private String secretKey;

    private Long tokenValidMillisecond = 60 * 60 * 1000L;

    private final CustomUserDetailsService userDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // Jwt 생성
    public String createToken(String userPk, List<String> roles) {

        // user 구분을 위해 Claims에 User Pk값 넣어줌
        Claims claims = Jwts.claims().setSubject(userPk);
        claims.put("roles", roles);
        // 생성날짜, 만료날짜를 위한 Date
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }


    // Jwt 로 인증정보를 조회
    public Authentication getAuthentication (String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // jwt 에서 회원 구분 Pk 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // HTTP Request 의 Header 에서 Token Parsing -> "X-AUTH-TOKEN: jwt"
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // jwt 의 유효성 및 만료일자 확인
    public boolean validationToken(String token) {
        try {
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return !claimsJws.getBody().getExpiration().before(new Date()); // 만료날짜가 현재보다 이전이면 false
        } catch (Exception e) {
            return false;
        }
    }
}
application-jwt.yml
spring:
  jwt:
    secret: {AccessToken}

보안을 위해 AccessToken은 application.yml 이 아니라 따로 application-XXX.yml 파일을 만들어서 추가하자.

 

application.yml에서 아래와 같이 사용

spring:
  profiles:
    include: jwt

2. JwtAuthenticationFilter 생성

Jwt이 유효한 토큰인지 인증하기 위한 Filter이다. 이 필터를 Security 설정 시 UsernamePasswordAuthentication 앞에 세팅해서 로그인폼으로 반환하기 전에 인증 여부를 Json으로 반환시킨다.

package com.restApi.restApiSpringBootApp.config.security;

// import ...

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtProvider jwtProvider;

    // request로 들어오는 Jwt의 유효성을 검증 - JwtProvider.validationToken() 을 필터로서 FilterChain에 추가
    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain filterChain) throws IOException, ServletException {
        String token = jwtProvider.resolveToken((HttpServletRequest) request);
        if (token != null && jwtProvider.validationToken(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
}

3. SpringSecurity Configuration 작성

서버에 보안을 적용한다.

  • 아무나 접근이 가능한 리소스 : permitAll()
  • 그외 나머지 : ROLE_USER 권한이 필요함을 명시

anyRequest.hasRole("USER")와 anyRequest.authenticated() 는 동일한 효과를 낸다.

! 참고 !
스프링 시큐리티 적용 후에는 모든 리소스에 대한 접근이 제한되므로 Swagger 페이지에는 예외처리르 해줘야 페이지에 접근이 가능하다

 

리소스 접근 제한 표현식

  • hasIpAddress(ip) : 접근자의 IP주소가 매칭하는지 확인
  • hasRole(role) : 접근자의 역활이 부여된 권한 (Garanted Authority) 와 일치하는지 확인
  • hasAnyRole(role,role...) : 부여된 역활 중 일치하는 항목이 있는지 확인
  • permitAll() : 모든 접근을 허용
  • denyAll() : 모든 접근을 불허
  • anonymous : 익명의 사용자인지 확인
  • authenticated : 인증된 사용자인지 확인
  • rememberMe : remember me를 사용해 인증했는지 확인
  • fullyAuthenticated : 사용자가 모든 크리덴셜을 갖춘 상태에서 인증했는지 확인
package com.restApi.restApiSpringBootApp.config.security;

// import ...

@RequiredArgsConstructor
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final JwtProvider jwtProvider;

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .authorizeRequests()
                        .antMatchers("/*/login", "/*/signup").permitAll()
                        .anyRequest().hasRole("USER")
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
                "/swagger-ui.html", "/webjars/**", "/swagger/**");
    }
}

httpBasic() : 기본설정은 비 인증시 로그인 폼 화면으로 리다이렉트 되는데 RestApi이므로 disalbe함

CSRF() : rest api이므로 상태를 저장하지 않으니 csrf 보안을 설정하지 않아도된다.

SessionCreationPolicy(SessionCreationPolicy.STATELESS)

  • Jwt으로 인증하므로 세션이 필요지 않으므로 생성 안한다.

authorizeRequests()

  • URL 별 권한 관리를 설정하는 옵션의 시작점, antMathcers를 작성하기 위해서는 먼저 선언되어야 한다.

antMatchers()

  • 권한 관리 대상을 지정하는 옵션
  • 로그인 및 가입에 대한 접근은 누구나 가능하도록 함 : antMathcers("/*/login", "/*/signup").permitAll()
  • 그 외 나머지 요청은 인증된 회원만 가능 : anyRequest().hasRole("USER")

addFilterBefore()

  • jwt 인증 필터를 UsernamePasswordAuthenticationFilter.class 전에 넣는다.

web.ignoring().antMatchers()

  • swagger 관련 url에 대해서는 예외처리

4. UserDetailsService를 implements받아서 재정의 - 토큰에 포함된 유저 정보로 유저 찾기

토큰에 세팅된 유저 정보로 회원정보를 조회하는 UserDetailsService(인터페이스)를 재정의한다.

@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserJpaRepo userJpaRepo;

    @Override
    public UserDetails loadUserByUsername(String userPk) throws UsernameNotFoundException {
        return userJpaRepo.findById(Long.parseLong(userPk)).orElseThrow(UserNotFoundCException::new);
    }
}

UserJpaRepo에서 반환하는 엔티티는 User인데 User가 UserDeatils를 상속하도록 하자.

 

스프링 시큐리티에서 토큰에 포함된 유저 정보로 유저를 조회하는 것을 UserDetailsService인터페이스에 만들어놨는데 여기에는 단 하나의 메소드 loadUserByUsername(String username)가 존재한다. 이 메소드에서 UserDetails (인터페이스)를 반환하도록 정의되어 있다.

 

따라서 User 엔티티가 UserDetails 인터페이스를 implements하면 된다.


5. User Entity가 UserDetails를 implements하도록 수정

스프링 시큐리티 보안을 User Entity에 적용하기 위해 UserDetails를 상속받아서 메소드를 오버라이드하자.

 

roles는 회원이 가지고 있는 권한 정보이고 가입 시 기본으로 "ROLE_USER"가 세팅된다. 권한은 회원당 여러개가 정의될 수 있으므로 컬렉션 타입으로 정의한다. (@ElementCollection)

 

Json 결과로 출력하지 않을 값들은 @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) 애노테이션을 선언해준다. read하지 못하게 하는 것이다. (특히 password는 꼭 읽지 못하게 하자)

 

스프링 시큐리티에서 제공하는 회원 정보 관련 세팅들이 있는데 사용여부에 따라 적절히 구현해준다. 여기서는 사용하지 않으므로 기본 true로 세팅.

 

스프링 시큐리티가 제공하는 회원 보안관련 메소드
  • isAccountNonExpired() : 계정이 만료되었는지 여부
  • isAccountNonLocked() : 계정이 잠겼는지 여부
  • isCredentialsNonExpired() : 계정 패스워드가 만료되었는지 여부
  • isEnabled() : 계정이 사용가능한지 여부

 

UserDeatils를 implemetns받아서 다시 태어난 User 엔티티

@Builder
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user")
public class User extends BaseTimeEntity implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(nullable = false, length = 100)
    private String password;

    @Column(nullable = false, unique = true, length = 30)
    private String email;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(length = 20)
    private String nickName;

    @ElementCollection
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    public void updateNickName(String nickName) {
        this.nickName = nickName;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles
                .stream().map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.email;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

6. 로그인 예외처리 추가

이미 가입된 이메일로 가입하는 EmailSignupFailedCException도 똑같이 만들어주면 된다.

로그인 예외 클래스 생성
package com.restApi.restApiSpringBootApp.advice.exception;

public class EmailLoginFailedCException extends RuntimeException {
    public EmailLoginFailedCException() {
        super();
    }

    public EmailLoginFailedCException(String message) {
        super(message);
    }

    public EmailLoginFailedCException(String message, Throwable cause) {
        super(message, cause);
    }
}

 

예외 통합 처리를 하는 @RestControllerAdvice에 로그인 예외 처리를 위한 @ExceptionHandler를 추가
    /***
     * 유저 이메일 로그인 실패 시 발생시키는 예외
     */
    @ExceptionHandler(EmailLoginFailedCException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    protected CommonResult emailLoginFailedException(HttpServletRequest request, EmailLoginFailedCException e) {
        return responseService.getFailResult(
                Integer.parseInt(getMessage("emailLoginFailed.code")), getMessage("emailLoginFailed.msg")
        );
    }

 

exception_en & exception_ko에 예외 코드와 예외 메시지 추가
# exception_en
emailLoginFailed:
  code: "-1001"
  msg: "가입하지 않은 아이디이거나, 잘못된 비밀번호입니다."

---
# exception_ko
emailLoginFailed:
  code: "-1001"
  msg: "It's an ID you didn't sign up for, or it's an incorrect password."

7.  가입 & 로그인 Controller, Service 구현 / 응답, 요청 DTO 추가

  • 가입 시에는 Password 인코딩을 위해서 passwordEncoder 설정을 해준다.
  • 로그인 성공 시 결과로 Jwt을 발급해준다.
SignController
@Api(tags = "1. SignUp / LogIn")
@RequiredArgsConstructor
@RestController
@RequestMapping("/v1")
public class SignController {

    private final UserService userService;
    private final JwtProvider jwtProvider;
    private final ResponseService responseService;
    private final PasswordEncoder passwordEncoder;

    @ApiOperation(value = "로그인", notes = "이메일로 로그인을 합니다.")
    @GetMapping("/login")
    public SingleResult<String> login(
            @ApiParam(value = "로그인 아이디 : 이메일", required = true) @RequestParam String email,
            @ApiParam(value = "로그인 비밀번호", required = true) @RequestParam String password) {
        UserLoginResponseDto userLoginDto = userService.login(email, password);

        String token = jwtProvider.createToken(String.valueOf(userLoginDto.getUserId()), userLoginDto.getRoles());
        return responseService.getSingleResult(token);
    }

    @ApiOperation(value = "회원가입", notes = "회원가입을 합니다.")
    @PostMapping("/signup")
    public SingleResult<Long> signup(
            @ApiParam(value = "회원 가입 아이디 : 이메일", required = true) @RequestParam String email,
            @ApiParam(value = "회원 가입 비밀번호", required = true) @RequestParam String password,
            @ApiParam(value = "회원 가입 이름", required = true) @RequestParam String name,
            @ApiParam(value = "회원 가입 닉네임", required = true) @RequestParam String nickName) {

        UserSignupRequestDto userSignupRequestDto = UserSignupRequestDto.builder()
                .email(email)
                .password(passwordEncoder.encode(password))
                .name(name)
                .nickName(nickName)
                .build();
        Long signupId = userService.signup(userSignupRequestDto);
        return responseService.getSingleResult(signupId);
    }
}
로그인 응답을 받을 UserLoginResponseDto
@Getter
public class UserLoginResponseDto {
    private final Long userId;
    private final List<String> roles;
    private final LocalDateTime createdDate;

    public UserLoginResponseDto(User user) {
        this.userId = user.getUserId();
        this.roles = user.getRoles();
        this.createdDate = user.getCreatedDate();
    }
}

 

회원가입 요청을 할 UserSignupRequestDto
@Getter
public class UserSignupRequestDto {
    private String email;
    private String password;
    private String name;
    private String nickName;

    @Builder
    public UserSignupRequestDto(String email, String password, String name, String nickName) {
        this.email = email;
        this.password = password;
        this.name = name;
        this.nickName = nickName;
    }

    public User toEntity() {
        return User.builder()
                .email(email)
                .password(password)
                .nickName(nickName)
                .name(name)
                .roles(Collections.singletonList("ROLE_USER"))
                .build();
    }
}

 

UserService - 로그인, 회원가입 구현
    @Transactional(readOnly = true)
    public UserLoginResponseDto login(String email, String password) {
        User user = userJpaRepo.findByEmail(email).orElseThrow(EmailLoginFailedCException::new);
        if (!passwordEncoder.matches(password, user.getPassword()))
            throw new EmailLoginFailedCException();
        return new UserLoginResponseDto(user);
    }

    @Transactional
    public Long signup(UserSignupRequestDto userSignupDto) {
        if (userJpaRepo.findByEmail(userSignupDto.getEmail()).orElse(null) == null)
            return userJpaRepo.save(userSignupDto.toEntity()).getUserId();
        else throw new EmailSignupFailedCException();
    }

passwordEncoder.matches(charSequence rawPassword, String encodedPassword)

  • PasswordEncoder 클래스 내 matches 메소드
  • 저장소에서 가져온 인코딩된 암호(rawPw)가 인코딩된 후 제출된 원시 암호(encodedPassword)와 일치하는지 확인한다.
  • 암호가 일치하면 true를 반환하고 일치하지 않으면 false를 반환. 저장된 암호 자체는 디코딩하지 않는다.
    • rawPassword – 인코딩 및 일치시킬 원시 암호
    • EncodedPassword – 스토리지에서 인코딩된 암호와 비교할 대상

 

PasswordEncoder @Bean 을 Application 클래스에 추가
@EnableJpaAuditing
@SpringBootApplication
public class Application {

	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
}

UserController 수정

유효한 Jwt토큰을 설정해야만 User리소스를 사용할 수 있도록 Header에 X-AUTH-TOKEN을 인자로 받도록 선언

@Api(tags = {"2. User"})
@RequiredArgsConstructor
@RestController
@RequestMapping("/v1")
public class UserController {

    private final UserService userService;
    private final ResponseService responseService;

    @ApiImplicitParams({
            @ApiImplicitParam(
                    name = "X-AUTH-TOKEN",
                    value = "로그인 성공 후 AccessToken",
                    required = true, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "회원 단건 검색", notes = "userId로 회원을 조회합니다.")
    @GetMapping("/user/id/{userId}")
    public SingleResult<UserResponseDto> findUserById
            (@ApiParam(value = "회원 ID", required = true) @PathVariable Long userId,
             @ApiParam(value = "언어", defaultValue = "ko") @RequestParam String lang) {
        return responseService.getSingleResult(userService.findById(userId));
    }

    @ApiImplicitParams({
            @ApiImplicitParam(
                    name = "X-AUTH-TOKEN",
                    value = "로그인 성공 후 AccessToken",
                    required = true, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "회원 단건 검색 (이메일)", notes = "이메일로 회원을 조회합니다.")
    @GetMapping("/user/email/{email}")
    public SingleResult<UserResponseDto> findUserByEmail
            (@ApiParam(value = "회원 이메일", required = true) @PathVariable String email,
             @ApiParam(value = "언어", defaultValue = "ko") @RequestParam String lang) {
        return responseService.getSingleResult(userService.findByEmail(email));
    }

    @ApiImplicitParams({
            @ApiImplicitParam(
                    name = "X-AUTH-TOKEN",
                    value = "로그인 성공 후 AccessToken",
                    required = true, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "회원 목록 조회", notes = "모든 회원을 조회합니다.")
    @GetMapping("/users")
    public ListResult<UserResponseDto> findAllUser() {
        return responseService.getListResult(userService.findAllUser());
    }

    @ApiImplicitParams({
            @ApiImplicitParam(
                    name = "X-AUTH-TOKEN",
                    value = "로그인 성공 후 AccessToken",
                    required = true, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "회원 수정", notes = "회원 정보를 수정합니다.")
    @PutMapping("/user")
    public SingleResult<Long> update (
            @ApiParam(value = "회원 ID", required = true) @RequestParam Long userId,
            @ApiParam(value = "회원 이름", required = true) @RequestParam String nickName) {
        UserRequestDto userRequestDto = UserRequestDto.builder()
                .nickName(nickName)
                .build();
        return responseService.getSingleResult(userService.update(userId, userRequestDto));
    }

    @ApiImplicitParams({
            @ApiImplicitParam(
                    name = "X-AUTH-TOKEN",
                    value = "로그인 성공 후 AccessToken",
                    required = true, dataType = "String", paramType = "header")
    })
    @ApiOperation(value = "회원 삭제", notes = "회원을 삭제합니다.")
    @DeleteMapping("/user/{userId}")
    public CommonResult delete(
            @ApiParam(value = "회원 아이디", required = true) @PathVariable Long userId) {
        userService.delete(userId);
        return responseService.getSuccessResult();
    }
}

Swagger 테스트 진행

오류 : Unable to serialize claims object to json.

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

User 객체의 권한이 담긴 컬렉션 객체를 User 조회시 EAGER로 즉시로딩하지 않는다면, Porxy객체가 담겨서 반환되므로 제대로 "ROLE_USER"를 확인할 수 없다.

 

해결 방법 : 따라서 EAGER를 통해 즉시로딩하도록 해야한다.

 

수행 결과

회원을 회원가입시키고, 로그인해보고, 로그인 시 발급되는 Jwt으로 회원을 조회해보고, 회원의 정보를 수정하는 테스트가 모두 정상적으로 수행되는 것을 확인할 수 있다.
반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday