
Web Security Configuration설정
。Web Security Configuration
。로그인시 사용하는API에 대해Spring Security에 의해인증에 대한검증을 수행하지 않도록 설정
private static final String[] POST_PERMIT_ALL = {"/members", "/auth/login"};
JWT 발급
JWT 발급
로그인 기능구현
。JWT Token의Refresh Token을 발급받기 위해로그인을 수행
。계정 생성시PasswordEncoder를 통해계정에Hashing된Password를 저장
▶해싱한 상태로비밀번호를DB에 저장하여DB가 해킹되어비밀번호를 탈취되더라도복호화는 불가능
。로그인시로그인 ID와로그인 PW를Request Body내 저장하여서버에 전달하여인증에 대한검증을 수행
▶로그인 PW를Hashing및DB에서로그인 ID에 해당하는계정의Hashing된PW를 꺼내서비교검증을 수행
계정을 관리하는Service Class에서Web Security Configuration에서 정의한PasswordEncoder를 활용하여JPA를 통해계정저장 시패스워드를암호화하여 저장
。@Configuration Class에서 정의한PasswordEncoder의Spring Bean을 주입 후JPA를 통해Entity로영속화시계정의password를Bcrypt 알고리즘으로해싱
▶Hashing은단방향 암호호이므로Hashing한 상태로비밀번호를DB에 저장 할 경우DB가 해킹되어비밀번호를 탈취되더라도복호화는 불가능@RequiredArgsConstructor @Service @Transactional // 트랜잭션의 원자성 보장 public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; // 계정 생성 @Override public void createMember(MemberRequest.Create request){ var member = MemberEntity.normalMember( request.loginId(), passwordEncoder.encode(request.password()), // PasswordEncoder로 암호화 request.name(), request.email(), request.mobile(), request.gender(), request.birthday() ); memberRepository.save(member); } }
Authentication을 수행하는Repository Layer의@Repository생성
。JPA를 통해loginId를 기반으로 검색 수행 시query method를 통해커스텀 메서드구축public interface MemberRepository extends JpaRepository<MemberEntity, Long> { Optional<MemberEntity> findByLoginId(String loginId); default MemberEntity findByLoginIdOrThrow(String loginId){ return findByLoginId(loginId).orElseThrow(()-> new CustomException(ErrorCode.FAIL_LOGIN))); } }▶
NPE방지
Authentication을 수행하는Service Layer의@Service Class생성
。PW를Hashing및 기존DB에 저장된LoginID에 해당하는계정의Hashing된PW를 조회하여비교검증수행
▶passwordEncoder객체.matches(평문패스워드 , 암호화패스워드);를 사용하여 검증
。password간비교검증이 수행된 경우클라이언트에게JWT 토큰을 발급하는서비스 로직으로Access Token과Refresh Token를 각각 발급 후컨트롤러로 반환
▶ 위에서 정의한JwtService 클래스를 통해조회한Member객체의Id를 전달하여Jwt Token발급 후사용자에게반환
。이때,Redis DB에 생성된Refresh Token을 전송하여 저장 Redis - Refresh Token 재발급@Service @Transactional @RequiredArgsConstructor public class AuthService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; private final PojoJwtProperties pojoJwtProperties; private final JwtService jwtService; @Override public Pair<String,String> LoginAccount(AccountRequest.Login request) { // Login ID로 기존 DB에 저장된 계정의 Hashin 된 PW를 조회 // Null 체크는 해당 Repository 인터페이스의 default 메서드에서 검증 Account foundedAccount = memberRepository.findByEmailOrThrow(request.email()); // 입력된 raw password와 DB의 hash password를 비교검증 PreConditions.validate( passwordEncoder.matches(request.password(),foundedAccount.getPassword()), ErrorCode.FAIL_LOGIN ); // PreConditions.validate( foundedAccount.getStatus().equals(AccountStatus.ACTIVATED), ErrorCode.FAIL_LOGIN ); // 비교검증 성공 시 각각 만료시간을 전달하여 Access Token과 Refresh Token을 생성하여 발급 후 Pair로 반환 String accessToken = jwtService.issue( foundedAccount.getId(), pojoJwtProperties.getJwt().accessTokenExpiration() ); // String refreshToken = jwtService.issue( foundedAccount.getId(), pojoJwtProperties.getJwt().refreshTokenExpiration() ); // Redis에 해당 RefreshToken을 저장 refreshTokenRepository.save( new RefreshToken( request.email(), refreshToken ) ); // return Pair.of(accessToken, refreshToken); } }
@RequestBody를Mapping할DTO정의
。사용자가loginId와password를HTTP Request Body에 포함 시바인딩@NoArgsConstructor(access = AccessLevel.PROTECTED) public class LoginRequest { public record Login( @NotBlank String loginId, @NotBlank String password ){ } }
Access Token을 포함하여클라이언트에게응답할DTO정의@NoArgsConstructor(access = AccessLevel.PROTECTED) public class LoginResponse { public record Login( String accessToken ){} }
서버는로그인 성공시 각각Access Token과Refresh Token을 발급하며Access Token은Response Body에 포함하여 ,Refresh Token은HttpOnly, Secure Cookie에 저장하여프론트엔드에게 전달
클라이언트에Refresh Token을HttpOnly,Secureflagf가 적용된Cookie에 저장하는 이유?
。Cookie에HttpOnly,Secureflagf를 적용하여쿠키를 안전하게 관리.
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
쿠키
。HttpOnly flagf를 적용하면클라이언트의JavaScript를 통한쿠키에 접근을 원천차단
▶CSRF/XSS 공격으로 부터쿠키값을 보호하므로Refresh Token을HttpOnly Cookie에 보관하는 이유
。Secure flagf를 적용 시TLS/SSL을 사용하는 HTTPS를 사용하는 경우에만쿠키를 전송하도록 설정되어네트워크중간에Refresh Token을 포함한쿠키에암호화가 적용되어 탈취되도 알 수없음.
。쿠키의수명은Redis DB에 저장하는Refresh Token과 동일하게 설정
XSS( Cross-Site Scripting )
▶브라우저에악성 JS를 주입해쿠키또는개인정보를 탈취
Authentication을 수행하는Controller Layer의@Controller Class생성
。login 용도 Controller는Request Body내ID와PW를 포함하여암호화하도록@PostMapping선언
▶Request Body내데이터는암호화되어송신자 브라우저가 아니면 확인할 수 없으므로
。Service Layer에Login ID와PW를 전달한 후로그인 성공시Service Layer에서 발급하여 전달한Access Token과Refresh Token을DTO에 포함 후ResponseBody를 통해클라이언트에게 응답
공통응답을 정의하는 커스텀클래스
。Access Token과Refresh Token을 각각 발급하여AT는Response Body,RT는Cookie에 담아서클라이언트에게 전송
▶ 이후사용자는만료된토큰을서버에 전달하는 경우Redis의Refresh Token과 함께 초기화 후 제공private static final String cookieName = "RT"; @Override @PostMapping("/login") public ResponseEntity<ApiResultResponse<AccountResponse.Login>> logIn( @RequestBody @Valid AccountRequest.Login login, HttpServletResponse response ) { // Pair<String,String> pairToken = authService.LoginAccount(login); // HttpOnly, Secure 쿠키 생성 Cookie cookie = new Cookie(cookieName,pairToken.getSecond()); cookie.setMaxAge(12*60*60); // 12시간 : Redis DB의 RT 수명과 동일하게 설정 cookie.setSecure(true); // HTTPS 에만 쿠키 전송 cookie.setHttpOnly(true); // JS에서 접근 불가능 cookie.setPath("/"); response.addCookie(cookie); // return ApiResultResponse.data( SuccessCode.LOGIN_SUCCESS, new AccountResponse.Login( pairToken.getFirst() ) ); }
▶AT는Response Body에,RT는Cookie에 포함되어 전송됨