회원가입과 비밀번호 암호화

그동안 회원가입이 없었기 때문에 data.sql 로 회원 정보를 넣고 그걸 바탕으로 로그인 로직을 수행했었다.

이제 회원가입 기능을 만들기 때문에 data.sql 은 사용하지 않으니 삭제하거나

yml 파일에서 sql.init.mode : never 로 바꿔주자.

비밀번호는 개인정보이므로 암호화해서 처리해야 한다.

 


기본 설정

spring security 라이브러리를 이용하여 암호화를 해보자.

아래처럼 의존성을 추가해준다.

build.gradle

implementation 'org.springframework.security:spring-security-crypto'
implementation 'org.bouncycastle:bcprov-jdk15on:1.70'

 

AuthController

Signup 객체에 회원가입 정보를 받아서 저장하는 컨트롤러를 작성한다.

@PostMapping("/auth/signup")
public void signup(@RequestBody Signup signup) {
    authService.signup(signup);
}

 

Signup

회원가입 정보를 담는 객체이다. 필요하다면 여기에 검증 기능도 넣으면 좋을것이다.

@Getter
@NoArgsConstructor
public class Signup {

    private String email;
    private String name;
    private String password;

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

 

AuthService

하는김에 중복 체크도 하는 회원가입 로직을 만든다.

중복 체크 후 비밀번호를 암호화해서 저장한다.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder encoder;

    @Transactional
    public void signup(Signup signup) {
        Optional<User> userOptional = userRepository.findByEmail(signup.getEmail());
        if (userOptional.isPresent()) {
            throw new AlreadyExistEmailException();
        }

        String encryptedPassword = encoder.encrypt(signup.getPassword());

        User user = User.builder()
            .email(signup.getEmail())
            .name(signup.getName())
            .password(encryptedPassword)
            .build();
        userRepository.save(user);
    }
}

 

PasswordEncoder

재사용성을 위해 PasswordEncoder 를 만들어서 여기서 암호화 관련 메서드를 처리한다.

@Component
public class PasswordEncoder {

    private static final PasswordEncoder encoder = new PasswordEncoder(
        16,
        8,
        1,
        32,
        64);

    public String encrypt(String rawPassword) {
        return encoder.encode(rawPassword);
    }

    public boolean matches(String rawPassword, String encryptedPassword) {
        return encoder.matches(rawPassword, encryptedPassword);
    }
}

 

AlreadyExistEmailException

중복 회원일 시 발생하는 커스텀 예외

public class AlreadyExistEmailException extends DunpleException {

    private static String MESSAGE = "이미 가입된 이메일입니다.";

    public AlreadyExistEmailException() {
        super(MESSAGE);
    }

    @Override
    public int getStatusCode() {
        return 400;
    }
}

 

UserRepository

중복체크를 위해 Email 로 가입된 회원이 존재하는지 확인하는 메서드를 만든다.

Optional<User> findByEmail(String Email);

 


테스트

AuthSerivceTest

AuthService 의 회원가입 성공, 실패 테스트를 작성한다.

@DisplayName("회원가입 성공")
@Test
void signup() {
    // given
    Signup signup = Signup.builder()
        .email("hyukkind@naver.com")
        .name("midcon")
        .password("1234")
        .build();

    // when
    authService.signup(signup);
    User user = userRepository.findByEmail("hyukkind@naver.com")
        .orElseThrow(() -> new InvalidSigninInformationException());

    // then
    assertEquals(1, userRepository.count());
    assertEquals("hyukkind@naver.com", user.getEmail());
    assertEquals("midcon", user.getName());
    assertTrue(encoder.matches("1234", user.getPassword()));
}

@DisplayName("중복된 이메일로 회원가입 시 예외가 발생")
@Test
void signup2() {
    // given
    User user = User.builder()
        .email("hyukkind@naver.com")
        .name("mika")
        .password("1234")
        .build();
    userRepository.save(user);

    Signup signup = Signup.builder()
        .email("hyukkind@naver.com")
        .name("midcon")
        .password("1234")
        .build();

    // expected
    assertThrows(AlreadyExistEmailException.class,
        () -> authService.signup(signup));
}

 

테스트는 성공하고 의도한대로 동작함을 알 수 있다.

 


AuthControllerTest

AuthController 의 회원가입 성공, 실패 테스트를 작성한다.

@DisplayName("회원가입 성공")
@Test
void signup() throws Exception {
    // given
    Signup signup = Signup.builder()
        .email("hyukkind@naver.com")
        .name("midcon")
        .password("1234")
        .build();

    String json = objectMapper.writeValueAsString(signup);

    // expected
    mockMvc.perform(post("/auth/signup")
            .content(json)
            .contentType(APPLICATION_JSON))
        .andExpect(status().isOk())
        .andDo(print());
}

@DisplayName("회원가입 실패")
@Test
void signup2() throws Exception {
    // given
    User user = User.builder()
        .email("hyukkind@naver.com")
        .name("midcon")
        .password(encoder.encrypt("1234"))
        .build();
    userRepository.save(user);

    Signup signup = Signup.builder()
        .email("hyukkind@naver.com")
        .name("mika")
        .password("1234")
        .build();

    String json = objectMapper.writeValueAsString(signup);

    // expected
    mockMvc.perform(post("/auth/signup")
            .content(json)
            .contentType(APPLICATION_JSON))
        .andExpect(status().isBadRequest())
        .andDo(print());
}

 

테스트는 성공하고 원하는대로 동작함을 알 수 있다.

JWT 를 이용한 인증 2

이전 게시글이 너무 길어져서 아래에서 이어서 작성한다.

최근 많이 쓰이는 JWT 를 이용해 DB 조회 없이 인증 로직을 만들어볼것이다.

인증 과정은 아래와 같다.

 

  1. 로그인 성공 시 JWT 를 만들어서 응답으로 JWT 를 발급
  2. 인증이 필요한 페이지에 요청 시 Authorization 헤더에 담긴 JWT 를 확인하여 인증

 


2. 인증이 필요한 페이지에 요청 시 Authorization 헤더에 담긴 JWT 를 확인하여 인증

AuthController

이전에 만들었던 인증이 필요한 애노테이션 @Login 이 달린 컨트롤러를 재사용한다.

이전에 만들어뒀던 UserSession 객체에 사용자의 이름을 담아온다.

@GetMapping("/auth")
public String access(@Login UserSession session) {
    return session.getName() + " 님 안녕하세요";
}

 

AuthResolver

AppConfig 에서 secretKey 값을 받아서 JWT를 검증한다.

인증 성공 시 JWT의 subject 에 담겨있던 사용자의 이름을 UserSession 객체에 담아 컨트롤러에 반환한다.

@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    private final AppConfig appConfig;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Login.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String jws = webRequest.getHeader("Authorization");
        if (jws == null || jws.isBlank()) {
            log.error("토큰이 없어요");
            throw new UnauthorizedException();
        }
        SecretKey key = Keys.hmacShaKeyFor(appConfig.getJwtKey());

        try {
            Jws<Claims> claimsJws = Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(jws);
            return new UserSession(claimsJws.getPayload().getSubject());
        } catch (JwtException e) {
            log.info("올바르지 않은 JWT 토큰 정보");
            throw new UnauthorizedException();
        }
    }
}

 

WebMvcConfig

AuthResolver 의 생성자가 바꼈으므로 해당 부분을 수정한다.

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final AppConfig appConfig;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthResolver(appConfig));
    }
}

 


테스트

AuthControllerTest

Mockito 를 이용하여 더 좋은 테스트를 작성할수도 있을것같지만 다음번에 해보도록 하겠다.

@DisplayName("검증된 세션 값으로 권한이 필요한 페이지에 요청시 접속에 성공한다.")
@Test
void access() throws Exception {
    // given
    User user = userRepository.findByEmailAndPassword("hyukkind@naver.com", "1234")
        .orElseThrow(() -> new InvalidSigninInformationException());

    // expected
    mockMvc.perform(
            get("/auth")
                .header("Authorization", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtaWRjb24ifQ.u_88_mWPpVXBTF1l4LcbYnhOJMS2Iksz25GFLmhgiKo")
                .contentType(APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isOk());
}

@DisplayName("검증되지 않은 세션값으로 권한이 필요한 페이지에 요청시 실패한다.")
@Test
void access2() throws Exception {
    // given
    User user = userRepository.findByEmailAndPassword("hyukkind@naver.com", "1234")
        .orElseThrow(() -> new InvalidSigninInformationException());

    // expected
    mockMvc.perform(
            get("/auth")
                .header("Authorization", "1")
                .contentType(APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isUnauthorized());
}

 

테스트는 성공하고 의도한대로 동작함을 알 수 있다.

JWT 란

JWT(Json Web Token) 는 Json 형식을 이용하여 사용자에 대한 속성을 저장하는 Web Token이다. 

JWT 에 관한 정보는 여기에 정리해두었다.

 

JWT 를 이용한 인증 1

최근 많이 쓰이는 JWT 를 이용해 DB 조회 없이 인증 로직을 만들어볼것이다.

기존의 인증 및 검증 과정은 DB 가 필요했지만 JWT 는 DB에서 확인하는 과정이 필요 없다는 장점이 있다.

인증 과정은 아래와 같다.

 

  1. 로그인 성공 시 JWT 를 만들어서 응답으로 JWT 를 발급
  2. 인증이 필요한 페이지에 요청 시 Authorization 헤더에 담긴 JWT 를 확인하여 인증

 


1. 로그인 성공 시 JWT 를 만들어서 응답으로 JWT 를 발급

이 글에서는 JWT를 만들고 응답에 넣어서 보내는것 까지만 설명한다.

인증을 처리하는 과정이 궁금하다면 다음 글을 확인하자.

AuthController

SecretKey 를 Application.yml 에 설정해두고 AppConfig 에서 이 설정 정보를 읽어온다.

이 AppConfig 에서 SecretKey를 꺼내와서 토큰 발급 및 검증에 사용한다.

application.yml 에서 프로퍼티 정보를 가져오는건 이 글에 정리해두었다.

로그인 성공 시 사용자의 이름과 발급 시간 정보가 담긴 JWT 토큰을 응답값에 담아 반환한다.

Claim 에 토큰 수명을 설정할수도 있다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;
    private final AppConfig appConfig;

    @PostMapping("/auth/login")
    public SessionResponse login(@RequestBody LoginRequest request) {
        String name = authService.signin(request);
        SecretKey secretKey = Keys.hmacShaKeyFor(appConfig.getJwtKey());
        // 토큰을 응답
        return new SessionResponse(Jwts.builder()
            .subject(name)
            .issuedAt(new Date())
            .signWith(secretKey)
            .compact());
    }
}

 

application.yml

midcon:
  jwt-key: "pjx7jVXbdaeOmw0ZO1SotIHLVApe8FZ+LmGCuMKa8T8="

 

AppConfig

AppConfig 에서 jwtKey 정보를 가져올때 굳이 인코딩 된 jwtKey 를 가져올 필요가 없으므로 AppConfig 에서 디코딩 한다.

@RequiredArgsConstructor
@ConstructorBinding
@ConfigurationProperties(prefix = "midcon")
public class AppConfig {

    private final String jwtKey;

    public byte[] getJwtKey() {
        return Decoders.BASE64.decode(jwtKey);
    }
}

 

AuthService

로그인 성공 시 유저 이름을 반환하도록 수정했다.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;

    @Transactional
    public String signin(LoginRequest request) {
        User user = userRepository.findByEmailAndPassword(request.getEmail(), request.getPassword())
            .orElseThrow(() -> new InvalidSigninInformationException());
        return user.getName();
    }
}

 


테스트

AuthServiceTest

서비스 로직에 변화가 생겨서 메서드가 반환한 유저 이름을 확인하는 테스트로 수정한다.

@DisplayName("로그인 요청 시 DB 정보와 일치하면 accessToken 을 발급한다.")
@Test
void signin() throws JsonProcessingException {
    // given
    LoginRequest request = LoginRequest.builder()
        .email("hyukkind@naver.com")
        .password("1234")
        .build();

    // when
    String name = authService.signin(request);
    User user = userRepository.findByEmailAndPassword(request.getEmail(), request.getPassword())
        .orElseThrow(() -> new InvalidSigninInformationException());

    // then
    assertEquals(user.getName(), name);
}

 

테스트는 통과하고 의도했던대로 동작함을 알 수 있다.

 


AuthControllerTest

로그인의 성공, 실패 테스트를 실행하고 body 에 JWT 가 응답으로 내려왔는지 확인하는 테스트를 작성한다.

Mockito 를 이용하여 JWT 토큰 값을 임의로 정해서 확인하는 테스트도 만들 수 있을것같지만 추후에 해보도록 하자.

@DisplayName("로그인 성공")
@Test
void login() throws Exception {
    // given
    LoginRequest request = LoginRequest.builder()
        .email("hyukkind@naver.com")
        .password("1234")
        .build();

    String json = objectMapper.writeValueAsString(request);

    // expected
    mockMvc.perform(
        post("/auth/login")
            .contentType(APPLICATION_JSON)
            .content(json)
        )
        .andDo(print())
        .andExpect(status().isOk());
}

@DisplayName("로그인 실패")
@Test
void login2() throws Exception {
    // given
    LoginRequest request = LoginRequest.builder()
        .email("hyukkind@naver.com")
        .password("1111")
        .build();

    String json = objectMapper.writeValueAsString(request);

    // expected
    mockMvc.perform(
            post("/auth/login")
                .contentType(APPLICATION_JSON)
                .content(json)
        )
        .andDo(print())
        .andExpect(status().isBadRequest());
}

 

테스트는 성공하고 원하는대로 JWT 또한 제대로 응답에 담겨서 내려오는걸 알 수 있다.

글이 길어져서 다음 글에서 이어서 작성한다.

쿠키를 통한 인증 및 검증

바로 전 게시글에서 만든 검증 방식은 Authorization 헤더에 accessToken 을 담아 주는 방식이었다.

이번엔 웹사이트에서 많이들 사용하는 쿠키를 사용하는 방식으로 바꿔보자.

기존의 accessToken 을 발급하는 방식은 그대로 사용하고 컨트롤러와 ArgumentResolver 를 변경한다.

인증 과정은 다음과 같다.

  1. 로그인 성공 시 응답으로 세션 토큰 발급
  2. 인증이 필요한 페이지에 요청 시 쿠키에 담긴 세션 토큰 정보로 DB 에서 확인

 

AuthController

AuthService 에서 만든 accessToken 을 쿠키에 담아 전달하는 컨트롤러를 만든다.

@PostMapping("/auth/login")
public ResponseEntity login(@RequestBody LoginRequest request) {
    // DB 에서 조회
    String accessToken = authService.signin(request);
    // accessToken 을 쿠키에 담아 전달
    ResponseCookie cookie = ResponseCookie.from("SESSION", accessToken)
        .domain("localhost")   // todo 서버 환경에 따라 설정파일로 분리해서 관리하자.
        .path("/")
        .httpOnly(true)
        .secure(false)
        .maxAge(Duration.ofDays(30))  // 한달이 국룰
        .sameSite("Strict")
        .build();
    return ResponseEntity.ok()
        .header(HttpHeaders.SET_COOKIE, cookie.toString())
        .build();
}

ㄴ ResponseCookie

accessToken 을 담아 줄 쿠키를 설정할 수 있는 클래스이다. 쿠키는 key & value 의 형태로 담긴다.

여기서는 SESSION 이라는 이름의 쿠키에 accessToken 값을 담았다.

domain 은 서버 환경에 따라 localhost 로 할 지, 실제 서버 도메인을 할 지 필요에 따라 설정 파일로 분리해서 관리한다.

httpOnly, secure, sameSite 와 관련된 정보는 중요하니 ReponseCookie 에 관한건 찾아보고 공부해두도록 하자.

maxAge 로 쿠키의 수명을 초 단위로 설정할 수도 있고 일 단위로 할 수도 있다. 한달이 국룰이라고 한다.

ㄴ ResponseEntity

응답으로 보내줄 상태 코드, 상태 메시지, 응답 바디 등을 설정할 수 있는 클래스이다.

여기서는 요청 성공 시 200 의 상태코드를 주기로 한다.

 

AuthResolver

기존의 헤더를 확인하던 검증 로직을 쿠키를 확인하는걸로 변경한다.

@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    private final SessionRepository sessionRepository;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Login.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest nativeRequest = webRequest.getNativeRequest(HttpServletRequest.class); // nullable 이므로 체크해줌
        if (nativeRequest == null) {
            log.error("servletRequest null");
            throw new UnauthorizedException();
        }

        Cookie[] cookies = nativeRequest.getCookies();
        if (cookies.length == 0) {
            log.error("쿠키가 없음");
            throw new UnauthorizedException();
        }
        // 데이터베이스로 사용자 확인작업
        String accessToken = cookies[0].getValue();
        Session session = sessionRepository.findByAccessToken(accessToken)
            .orElseThrow(() -> new UnauthorizedException());

        return new UserSession(session.getUser().getName());
    }
}

ㄴgetNativeRequest()

이 메서드를 이용하여  HttpServletRequest 를 가져와서 쿠키를 확인한다.

HttpServletRequest 는 nullable 이므로 null 체크를 해주었다.

 


테스트

AuthControllerTest

AuthResolver 의 로직이 바뀌었기 때문에 컨트롤러 테스트도 바꿔줘야한다.

권한이 필요한 페이지에 요청 시 SESSION 이라는 쿠키에  accessToken 이 담겼는지 확인하고

올바른 accessToken 일 시 접속을 허용하는 테스트를 작성한다.

@DisplayName("검증된 세션 값으로 권한이 필요한 페이지에 요청시 접속에 성공한다.")
@Test
@Transactional
void access() throws Exception {
    // given
    User user = userRepository.findByEmailAndPassword("hyukkind@naver.com", "1234")
        .orElseThrow(() -> new InvalidSigninInformationException());
    Session session = user.addSession();

    // expected
    mockMvc.perform(
            get("/auth")
                .cookie(new Cookie("SESSION", session.getAccessToken()))
                .contentType(APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isOk());
}

@DisplayName("검증되지 않은 세션값으로 권한이 필요한 페이지에 요청시 실패한다.")
@Test
@Transactional
void access2() throws Exception {
    // given
    User user = userRepository.findByEmailAndPassword("hyukkind@naver.com", "1234")
        .orElseThrow(() -> new InvalidSigninInformationException());
    Session session = user.addSession();

    // expected
    mockMvc.perform(
            get("/auth")
                .cookie(new Cookie("SESSION", "1"))
                .contentType(APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isUnauthorized());
}

 

테스트는 성공하고 쿠키값에 담긴 accessToken 이 DB 정보와 일치하지 않으면 예외가 발생한다.

DB를 통한 토큰 발급 및 검증 2

이전 게시글이 너무 길어져서 아래에서 이어서 작성한다.

지금까지 공부한 지식을 활용해서 한번 마음대로 인증 로직을 작성할것이다. 인증 과정은 아래와 같다.

  1. 로그인 성공 시 응답으로 세션 토큰 발급
  2. 인증이 필요한 페이지에 요청 시 헤더에 담긴 세션 토큰 정보로 DB 에서 확인

 

2. 인증이 필요한 페이지에 요청 시 헤더에 담긴 세션 토큰 정보로 DB 에서 확인

ArgumentResolver 를 활용해 애노테이션 기반 인증 로직을 만든 것을 활용해보자.

애노테이션 기반 인증 로직 관련은 이 글을 참고하자.

이제 인증이 필요한 페이지에 애노테이션 @Login 을 달아서 구분하고 이 페이지에 접속 시 세션 토큰을 DB를 통해 검증한다.

 

AuthController

인증이 필요한 페이지에 애노테이션 @Login 을 달아서 구분하고 UserSession 객체에서 이름을 받아 응답값을 반환하는

컨트롤러를 만든다.

@GetMapping("/auth")
public String access(@Login UserSession session) {
    return session.getName() + " 님 안녕하세요";
}

 

AuthResolver

ArgumentResolver를 이용해 애노테이션 @Login으로 인증 로직을 실행할지 말지 구분하고 @Login이 달렸을 경우

Authorization 헤더에 담긴 세션 토큰 정보로 DB에서 사용자 확인작업을 수행한다.

올바른 요청일 시 UserSession 객체에 유저의 이름을 담아 컨트롤러에 보내준다.

@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    private final SessionRepository sessionRepository;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Login.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String accessToken = webRequest.getHeader("Authorization");
        if (accessToken == null || accessToken.isBlank()) {
            throw new UnauthorizedException();
        }
        Session session = sessionRepository.findByAccessToken(accessToken)
            .orElseThrow(() -> new UnauthorizedException());

        return new UserSession(session.getUser().getName());
    }
}

 

UserSession

@Getter
public class UserSession {

    private final String name;

    public UserSession(String name) {
        this.name = name;
    }
}

 


테스트

AuthControllerTest

data.sql 을 통해 초기 유저 데이터가 입력된걸 이용한다.

세션토큰으로 인증이 필요한 페이지에 요청 시 성공 및 실패 케이스를 작성한다.

사실 테스트에서 data.sql 이나 @Transactional 을 사용하는건 바람직하지 않지만 지금은 간단하게 테스트 해보자.

@DisplayName("검증된 세션 값으로 권한이 필요한 페이지에 요청시 접속에 성공한다.")
@Test
@Transactional
void access() throws Exception {
    // given
    User user = userRepository.findByEmailAndPassword("hyukkind@naver.com", "1234")
        .orElseThrow(() -> new InvalidSigninInformationException());
    Session session = user.addSession();

    // expected
    mockMvc.perform(
            get("/auth")
                .header("Authorization", session.getAccessToken())
                .contentType(APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isOk());
}

@DisplayName("검증되지 않은 세션값으로 권한이 필요한 페이지에 요청시 실패한다.")
@Test
@Transactional
void access2() throws Exception {
    // given
    User user = userRepository.findByEmailAndPassword("hyukkind@naver.com", "1234")
        .orElseThrow(() -> new InvalidSigninInformationException());
    Session session = user.addSession();

    // expected
    mockMvc.perform(
            get("/auth")
                .header("Authorization", 1)
                .contentType(APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isUnauthorized());
}

 

테스트를 수행하면 성공하며 인증 성공 시 UserSession 에 담긴 이름으로 응답이 오는걸 확인할 수 있다.

DB를 통한 토큰 발급 및 검증 1

지금까지 공부한 지식을 활용해서 한번 마음대로 인증 로직을 작성할것이다. 인증 과정은 아래와 같다.

  1. 로그인 성공 시 응답으로 세션 토큰 발급
  2. 인증이 필요한 페이지에 요청 시 헤더에 담긴 세션 토큰 정보로 DB 에서 확인

 


1. 로그인 시 세션 토큰 발급

data.sql 을 이용해 DB에 초기데이터를 넣어둔다. 초기 데이터 설정은 이 글을 참고한다.

로그인 요청 데이터가 DB 데이터와 일치하면 세션 토큰(accessToken)을 발급하고 DB에 이 세션 토큰을 을 저장한다.

 

AuthController

JSON 을 통해 로그인 요청을 받는다. 로그인 성공 시 세션 토큰을 발급한다.

@PostMapping("/auth/login")
public SessionResponse login(@RequestBody LoginRequest request) {
    // DB 에서 조회
    String accessToken = authService.signin(request);
    // 토큰을 응답
    return new SessionResponse(accessToken);
}

 

LoginRequest

@Getter
@ToString
@NoArgsConstructor
public class LoginRequest {

    @NotBlank(message = "이메일을 입력해주세요.")
    private String email;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    @Builder
    public LoginRequest(String email, String password) {
        this.email = email;
        this.password = password;
    }
}

 

AuthService

DB 데이터와 비교하여 일치하면 해당 유저에게 세션 토근을 발급한다.

User는 Session과 연관관계가 맺어져있으므로 유저가 세션을 발급받으면 트랜잭션 종료 시 세션 정보가 DB에 저장된다.

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;

    @Transactional
    public String signin(LoginRequest request) {
        User user = userRepository.findByEmailAndPassword(request.getEmail(), request.getPassword())
            .orElseThrow(() -> new InvalidSigninInformationException());

        Session session = user.addSession();
        return session.getAccessToken();
    }
}

 

InvalidSigninInformationException

로그인 요청 실패시 발생하는 커스텀 예외.

public class InvalidSigninInformationException extends DunpleException{

    private static final String MESSAGE = "아이디/비밀번호가 올바르지 않습니다.";

    public InvalidSigninInformationException() {
        super(MESSAGE);
    }

    @Override
    public int getStatusCode() {
        return HttpStatus.BAD_REQUEST.value();
    }
}

 

User

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

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

    private String name;

    private String email;

    private String password;

    private LocalDateTime createdAt;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "user")
    private List<Session> sessions = new ArrayList<>();

    @Builder
    public User(String name, String email, String password) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.createdAt = LocalDateTime.now();
    }

    public Session addSession() {
        Session session = Session.builder()
            .user(this)
            .build();
        sessions.add(session);
        return session;
    }
}

 

Session

세션토큰은 UUID 로 발급한다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Session {


    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String accessToken;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @Builder
    public Session(User user) {
        this.accessToken = randomUUID().toString();
        this.user = user;
    }
}

 

SessionRespostiory

public interface SessionRepository extends JpaRepository<Session, Long> {
    Optional<Session> findByAccessToken(String accessToken);
}

 


테스트

AuthServiceTest

로그인 요청 성공 시 발급 받은 세션 토큰과 DB 의 세션 토큰이 일치하는지 확인하는 테스트와 로그인 실패 시

예외가 발생하는지 확인하는 테스트를 작성한다.

@SpringBootTest
class AuthServiceTest {

    @Autowired
    private AuthService authService;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SessionRepository sessionRepository;

    @AfterEach
    void tearDown() {
        sessionRepository.deleteAll();
    }

    @DisplayName("로그인 요청 시 DB 정보와 일치하면 accessToken 을 발급한다.")
    @Test
    void signin() throws JsonProcessingException {
        // given
        LoginRequest request = LoginRequest.builder()
            .email("hyukkind@naver.com")
            .password("1234")
            .build();

        // when
        String accessToken = authService.signin(request);
        Session session = sessionRepository.findByAccessToken(accessToken)
            .orElseThrow(() -> new InvalidSigninInformationException());

        // then
        assertEquals(accessToken, session.getAccessToken());
        assertEquals(1L, sessionRepository.count());
    }

    @DisplayName("로그인 요청 시 DB 정보와 일치하지 않으면 예외가 발생한다.")
    @Test
    void signin2() throws JsonProcessingException {
        // given
        LoginRequest request = LoginRequest.builder()
            .email("hyukkind@naver.com")
            .password("1111")
            .build();

        // expected
        assertThrows(
            InvalidSigninInformationException.class,
            () -> authService.signin(request)
        );
    }

 

테스트는 성공하고 의도한 대로 동작함을 알 수 있다.

 


AuthControllerTest

로그인 요청 시 DB 를 통해 성공 및 실패를 확인하는 테스트를 작성한다.

@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private SessionRepository sessionRepository;
    
    @AfterEach
    void tearDown() {
        sessionRepository.deleteAll();
    }

    @DisplayName("로그인 성공")
    @Test
    void login() throws Exception {
        // given
        LoginRequest request = LoginRequest.builder()
            .email("hyukkind@naver.com")
            .password("1234")
            .build();

        String json = objectMapper.writeValueAsString(request);

        // expected
        mockMvc.perform(
            post("/auth/login")
                .contentType(APPLICATION_JSON)
                .content(json)
            )
            .andDo(print())
            .andExpect(status().isOk());

        assertEquals(1L, sessionRepository.count());
    }

    @DisplayName("로그인 실패")
    @Test
    void login2() throws Exception {
        // given
        LoginRequest request = LoginRequest.builder()
            .email("hyukkind@naver.com")
            .password("1111")
            .build();

        String json = objectMapper.writeValueAsString(request);

        // expected
        mockMvc.perform(
                post("/auth/login")
                    .contentType(APPLICATION_JSON)
                    .content(json)
            )
            .andDo(print())
            .andExpect(status().isBadRequest());

        assertEquals(0, sessionRepository.count());
    }
}

 

테스트는 성공하고 성공 시 응답값으로 세션토큰 또한 발급되는걸 확인할 수 있다.

 

너무 길어져서 발급된 세션 토큰을 이용해 권한 인증이 필요한 페이지에 접속하는건 다음글로 넘기겠다.

ArgumentResolver

ArgumentResolver 에 관한 설명은 이 글을 참고한다.

기존 Interceptor 를 사용하는 방식도 괜찮지만 이번엔 ArgumentResolver 를 사용해보자.

커스텀 애노테이션 @Login 을 만들고 이 애노테이션이 붙은 메서드만 인증 로직을 적용할 것이다.

 

PostController

커스텀 애노테이션 @Login 이 붙은 컨트롤러를 만든다. 이 컨트롤러는 userSession 의 hello 필드의 값을 반환한다.

@GetMapping("/hello")
public String hello(@Login UserSession userSession) {
    return userSession.getHello();
}

 

Login

커스텀 애노테이션을 만드는법은 생각보다 간단하다.

아래처럼 설정만하면 된다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

ㄴ @Target(ElementType.PARAMETER)

우리가 만들 커스텀 애노테이션이 동작할 범위를 정한다.

파라미터에만 사용할 수 있도록 설정한다.

ㄴ @Retention(RetentionPolicy.RUNTIME)

우리가 만들 커스텀 애노테이션의 라이프 사이클을 정한다.

리플렉션 등을 활용할 수 있도록 런타임까지 애노테이션 정보가 남아있도록 설정한다.

 

UserSession

@Getter
public class UserSession {

    private final String hello;

    public UserSession(String hello) {
        this.hello = hello;
    }
}

 

AuthResolver

@Login 애노테이션이 달려있는지 확인하고 달려있다면 인증을 진행하는 ArgumentResolver 를 만든다.

public class AuthResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Login.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String authorization = webRequest.getHeader("Authorization");
        if (authorization == null || authorization.isBlank()) {
            throw new UnauthorizedException();
        }
        return new UserSession("Hello World");
    }
}

1. supportsParameter()

ㄴhasParameterAnnotation()

해당 애노테이션이 달려있는지 확인하고 달려있을 시 resolveArgument() 메서드를 실행한다.

여기서는 @Login 을 체크한다.

2. resolveArgument()

우선은 간단하게 Authorization 헤더를 갖고있는지, 빈값이 아닌지만 체크한다.

갖고 있는 경우 hello 필드에 "Hello World" 값을 갖는 UserSession 객체를 만든다.

 

WebMvcConfig

AuthResolver 를 사용하기 위해 WebMvcConfig 에 추가한다.

기존의 Interceptor 를 사용하던 방식은 당분간 사용하지 않을것이므로 WebMvcConfig 에서 주석처리한다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

//    @Override
//    public void addInterceptors(InterceptorRegistry registry) {
//        registry.addInterceptor(new AuthInterceptor())
//            .excludePathPatterns("/error", "/favicon.ico");
//    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthResolver());
    }
}

ㄴaddArgumentResolvers

위 메서드를 오버라이드 해서 우리가 만든 AuthResolver 를 추가한다.

 


PostControllerTest

Authorization 헤더를 추가하면 Hello World 를 출력하는 테스트를 작성한다.

@DisplayName("/hello 요청시 Hello World 를 출력한다.")
@Test
void hello() throws Exception {
    mockMvc.perform(
        MockMvcRequestBuilders.get("/hello")
            .header("Authorization","midcon")
        )
        .andExpect(status().isOk())
        .andExpect(content().string("Hello World"))
        .andDo(print());
}

 

테스트는 성공하고 우리가 의도한대로 동작한다.

만약 헤더값을 빼고 테스트를 돌리면 아래와 같이 예외가 터지며 인증이 필요하다는 메시지를 반환할것이다.

 

의문

그런데 API 하나하나 만들때 마다 인증이 필요한 부분에 검증 로직을 넣어야하는가?

전에도 언급했듯 3번 이상 반복되는 행위는 내가 뭔가 잘못하고있는게 아닌가? 하는 의문을 가져야한다.

그런 일들은 대개 해결방법이 있고, 없는 경우 설계가 잘못됐는지 의심을 해봐야한다.

 


인터셉터

이런 반복되는 작업인 공통관심사를 인터셉터를 이용해 해결해볼것이다.

인터셉터에 관해서는 여기에 정리해두었다.

이전 게시글에서 했던 작업들은 원래대로 롤백하고 진행한다.

 

PostController

간단하게 Get 요청 시 Hello World 를 반환하는 컨트롤러를 만든다.

@GetMapping("/hello")
public String hello() {
    return "Hello World";
}

 

AuthInterceptor

요청 헤더의 accessToken 에 "midcon" 이라는 값이 담겼는지 확인하는 인증 로직을 수행하는 인터셉터를 만든다.

@Slf4j
public class AuthInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info(">> preHandle");
        String accessToken = request.getHeader("accessToken");
        if (accessToken != null && accessToken.equals("midcon")) {
            return true;
        }
        throw new UnauthorizedException();
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info(">> postHandle");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info(">> afterCompletion");
    }
}

 

UnauthorizedException

인증 실패시 발생하는 커스텀 예외

public class UnauthorizedException extends DunpleException{

    private static final String MESSAGE = "인증이 필요합니다.";

    public UnauthorizedException() {
        super(MESSAGE);
    }

    @Override
    public int getStatusCode() {
        return HttpStatus.UNAUTHORIZED.value();
    }
}

 

WebMvcConfig

인터셉터를 적용하기 위해 WebMvcConfig 를 설정한다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor());
    }
}

 

PostControllerTest

accessToken 이라는 헤더에 "midcon" 이라는 값을 넣어서 Get 요청을 보낸다.

@DisplayName("/hello 요청시 Hello World 를 출력한다.")
@Test
void hello() throws Exception {
    mockMvc.perform(
        MockMvcRequestBuilders.get("/hello")
            .header("accessToken","midcon")
        )
        .andExpect(status().isOk())
        .andExpect(content().string("Hello World"))
        .andDo(print());
}

 

의도한대로 accessToken 헤더에 "midcon" 을 넣어주면 테스트는 성공하고 

아래처럼 잘못된 값을 넣거나 아예 넣지 않으면 401 에러가 뜨면서 실패한다.

 

 


특정 페이지는 인증을 넣고 다른건 넣기 싫다면?

지금까지 한 방식대로는 모든 페이지에서 인증과정을 거쳐야한다.

하지만 우리의 서비스는 충분히 인증이 필요한 페이지가 있고 필요없는 페이지가 있을 수 있다.

특정 URL 의 요청에만 인터셉터를 적용하거나 특정 URL 만 적용하지 않을 수는 없을까?

 

WebMvcConfig

WebMvcConfig 에 적용할 URL 을 넣거나 적용하지 않을 수 있다.

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new AuthInterceptor())
        .excludePathPatterns("/hello")
        .addPathPatterns("/posts");
}

ㄴ excludePathPatterns

입력한 URL 패턴에선 인터셉터를 적용하지 않는다.

ㄴ addPathPatterns

입력한 URL 패턴에선 인터셉터를 적용한다.

+ Recent posts