Spring Security - JWT를 통한 로그인 기능 구현

TopOfTheHead·2025년 11월 20일

Spring Security

목록 보기
14/25
post-thumbnail

Web Security Configuration 설정
Web Security Configuration
로그인 시 사용하는 API에 대해 Spring Security에 의해 인증에 대한 검증을 수행하지 않도록 설정
private static final String[] POST_PERMIT_ALL = {"/members", "/auth/login"};

JWT 발급
JWT 발급

로그인 기능 구현
JWT TokenRefresh Token을 발급받기 위해 로그인 을 수행

계정 생성PasswordEncoder를 통해 계정HashingPassword를 저장
해싱한 상태로 비밀번호DB에 저장하여 DB가 해킹되어 비밀번호를 탈취되더라도 복호화는 불가능

로그인로그인 ID로그인 PWRequest Body 내 저장하여 서버에 전달하여 인증에 대한 검증을 수행
로그인 PWHashingDB에서 로그인 ID에 해당하는 계정HashingPW를 꺼내서 비교검증을 수행

  • 계정을 관리하는 Service Class에서 Web Security Configuration에서 정의한 PasswordEncoder를 활용하여 JPA를 통해 계정 저장 시 패스워드암호화하여 저장
    @Configuration Class에서 정의한 PasswordEncoderSpring Bean을 주입 후 JPA를 통해 Entity영속화계정passwordBcrypt 알고리즘으로 해싱
    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 생성
    PWHashing 및 기존 DB에 저장된 LoginID에 해당하는 계정HashingPW를 조회하여 비교검증 수행
    passwordEncoder객체.matches(평문패스워드 , 암호화패스워드);를 사용하여 검증

    password비교검증이 수행된 경우 클라이언트에게 JWT 토큰을 발급하는 서비스 로직으로 Access TokenRefresh 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);
	}
}
  • @RequestBodyMappingDTO 정의
    사용자loginIdpasswordHTTP 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 TokenRefresh Token을 발급하며 Access TokenResponse Body에 포함하여 , Refresh TokenHttpOnly, Secure Cookie에 저장하여 프론트엔드에게 전달

  • 클라이언트Refresh TokenHttpOnly, Secure flagf가 적용된 Cookie에 저장하는 이유?
    CookieHttpOnly, Secure flagf를 적용하여 쿠키를 안전하게 관리.

    Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
    쿠키
    HttpOnly flagf를 적용하면 클라이언트JavaScript를 통한 쿠키에 접근을 원천차단
    CSRF / XSS 공격으로 부터 쿠키값을 보호하므로 Refresh TokenHttpOnly Cookie에 보관하는 이유

    Secure flagf를 적용 시 TLS/SSL을 사용하는 HTTPS를 사용하는 경우에만 쿠키를 전송하도록 설정되어 네트워크 중간에 Refresh Token을 포함한 쿠키암호화가 적용되어 탈취되도 알 수없음.

    쿠키수명Redis DB에 저장하는 Refresh Token과 동일하게 설정

    XSS( Cross-Site Scripting )
    브라우저악성 JS를 주입해 쿠키 또는 개인정보를 탈취

Authentication을 수행하는 Controller Layer@Controller Class 생성
login 용도 ControllerRequest BodyIDPW를 포함하여 암호화하도록 @PostMapping 선언
Request Body데이터암호화되어 송신자 브라우저가 아니면 확인할 수 없으므로

Service LayerLogin IDPW를 전달한 후 로그인 성공Service Layer에서 발급하여 전달한 Access TokenRefresh TokenDTO에 포함 후 ResponseBody를 통해 클라이언트에게 응답
공통응답을 정의하는 커스텀클래스

Access TokenRefresh Token을 각각 발급하여 ATResponse Body, RTCookie에 담아서 클라이언트에게 전송
▶ 이후 사용자만료토큰서버에 전달하는 경우 RedisRefresh 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()
			)
		);
	}



ATResponse Body에, RTCookie에 포함되어 전송됨

profile
공부기록 블로그

0개의 댓글