2024.01.07 - [Spring Boot] - [Spring Boot] 로그인 기능 구현 (0) - 공통 기능, 코드 구현
0. 스프링 시큐리티란 ?
스프링 시큐리티에 대한 내용은 이전 게시물 ( 2024.01.07 - [Spring Boot] - [Spring Boot] 로그인 기능 구현 (3) - 스프링 시큐리티 로그인) 참고
1. JWT 란 ?
- JWT (Jason Web Token) : 두 개체에서 JSON 객체를 사용해서 정보를 안전하게 전송하기 위한 방법
- JWT를 이용하면 웹 서버가 유저에 대한 정보를 기억할 필요 없이 토큰이 유효한지만을 확인하면 됨
2. JWT 구조
- JWT는 세 부분으로 구성됨
- 헤더 (Header)
- JWT 임을 명시 (토큰의 타입)
- 사용된 암호화 알고리즘 정보가 담겨 있음
- 페이로드 (Payload)
- 토큰의 본문에 해당
- 토근을 발급받는 대상에 대한 정보나 권한 등이 담겨 있음
- 시그니처 (Signature)
- 시그니처를 통해서 토큰의 무결성 검증 가능
- 헤더와 페이로드를 이용해 생성된 해쉬 : 암호화 알고리즘 ( (BASE64(Header) + BASE64(Payload) + 암호화키 )
- 헤더 (Header)
JWT의 구조와 인증방식등은 ( https://jwt.io ) 에서 확인 가능
3. JWT 특징
- JWT의 내부 정보에 해당하는 헤더와 페이로드는 단순 BASE64 방식으로 인코딩하므로 누구나 쉽게 디코딩 할 수 있음
- 따라서 외부에서 열람해도 되는 정보만 담아야 함 => 비밀번호 같은 정보는 포함하면 안됨!
- 시그니처는 사용한 secret key를 알아야만 디코딩 가능함
- 장점
- 서버의 부하를 줄일 수 있음
- 사용자 인증 정보를 안전하게 전송 가능
- 단점
- 토큰이 탈취되면 정보가 유출될 수 있음
- 한 번 발급된 토큰의 권한 변경이 어려움
4. JWT 기반 인증 과정
- 로그인 요청 : 사용자가 서버에 ID와 비밀번호를 담은 로그인 요청을 보냄
- 정보 확인 및 토큰 생성 : 사용자의 정보가 맞다면 토큰을 생성함
- 토큰 발급 : 생성한 토큰을 사용자에게 전달
- 토큰 저장 : 사용자는 받은 토큰을 저장함 (웹의 경우 주로 쿠키나 로컬 스토리지에 저장)
- 토큰 전송 : 사용자는 이후 해당 웹사이트의 모든 요청에 대해서 발급받은 토큰을 포함해서 보냄 => 주로 HTTP 헤더의 Authorization 태크에 토큰을 포함
- 토큰 검증 : 서버는 매 요청마다 토큰을 검증함, 토큰이 변조되었거나 유효기간이 만료되었다면 요청을 거부함
- 요청 처리 : 토큰이 유효하다면 요청을 처리하고 사용자에게 결과 전달
5. JWT 기반 인가 과정
- 토큰 검증 : 위의 (4-6. 토큰검증) 까지 동일
- 권한 확인 : 토큰이 유효할 때, 사용자 정보를 확인해서 해당 요청을 처리할 권한이 있는지 확인함
- 요청 처리/거부 : 권한이 올바르다면 요청을 처리하고 권한이 없다면 요청을 거부함
6. 구현 전 JWT 인증 방식 시큐리티 동작 원리 정리
- 회원가입 : 기존의 세션 방식과 동일
- 로그인 (인증) : 로그인 요청을 받은 후 토큰을 생성하여 응답
- 경로 접근 (인가) : JWT Filter를 통해서 요청의 헤더(Authorization)에서 JWT를 찾아 검증하고 일시적 요청에 대한 Session 생성 => 생성된 Session은 요청이 종료할 시 소멸됨
7. 스프링 시큐리티 사용 JWT 로그인 구현
7-1. 의존성 추가
- build.gradle
- 사용 버전에 따라 한 개만 추가할 것
// 0.12.3 버전
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
// 0.11.5 버전
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
7-2. 암호화 키 (secret key) 저장
- application.yml
- 암호화 키는 하드코딩 방식으로 구현 내부에 탑재하는 것을 지양하기 때문에 변수 설정 파일에 저장하는 것이 좋음
spring:
jwt:
secret: [사용할 암호화 키]
7-3. JWTUtil
@Component
public class JWTUtil {
private SecretKey secretKey;
// @Value : application.yml에서의 특정한 변수 데이터를 가져올 수 있음
// string key는 jwt에서 사용 안하므로 객체 키 생성!
// "${spring.jwt.secret}" : application.yml에 저장된 spring: jwt: secret 에 저장된 암호화 키 사용
public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
// loginId 반환 메서드
public String getLoginId(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("loginId", String.class);
}
// role 반환 메서드
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
// 토큰이 소멸 (유효기간 만료) 하였는지 검증 메서드
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
// 토큰 생성 메서드
public String createJwt(String loginId, String role, Long expiredMs) {
return Jwts.builder()
.claim("loginId", loginId)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
- secret key 를 생성자 주입 방식으로 주입
- 토큰 검증하는 3개의 메서드 구현
- loginId 반환 메서드
- role 반환 메서드
- 토큰이 유효한지 검증하는 메서드
- 토큰 생성하는 메서드 구현
- 토큰에 포함되는 정보는 loginId, role, 유효기간
- issuedAt() : 토큰 현재 발행 시간 설정
- expiration() : 토큰 소멸 시간 설정
- signWith() : 주입한 secret key를 통해서 암호화 진행
- compact() : 토큰을 compact 해서 리턴
7-4. LoginFilter
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String loginId = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(loginId, password, null);
return authenticationManager.authenticate(authToken);
}
// 로그인 성공 시
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authentication) {
// username 추출
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername();
// role 추출
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
// JWTUtil에 token 생성 요청
String token = jwtUtil.createJwt(username, role, 60*60*1000L);
// JWT를 response에 담아서 응답 (header 부분에)
// key : "Authorization"
// value : "Bearer " (인증방식) + token
response.addHeader("Authorization", "Bearer " + token);
}
// 로그인 실패 시
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) {
// 실패 시 401 응답코드 보냄
response.setStatus(401);
}
}
- UsernamePasswordAuthenticationFilter를 상속받은 LoginFilter 구현 => UsernamePasswordAuthenticationFilter의 자리에 새로 생성한 LoginFilter를 넣을것임
- 로그인 성공 시 JWTUtil을 통해서 토큰 생성함
- HTTP 인증 방식은 RFC 7235 정의에 따라서 " Authorization: 타입 인증토큰 " 과 같은 형태를 지녀야 함
- 따라서 생성한 토큰 앞에 타입인 "Bearer " 접두사를 붙여서 HTTP의 헤더에 추가
- 로그인 실패 시 401 응답코드를 보냄
7-5. JWTFilter
- 스프링 시큐리티 filter chain 요청에 담긴 JWT를 검증하기 위한 커스텀 필터
- JWTFilter를 통해서 요청 헤더 Authorization에 담긴 키를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다.
- 생성된 세션은 STATELESS 상태이므로 요청이 끝나면 소멸한다.
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request에서 Authorization 헤더 찾음
String authorization = request.getHeader("Authorization");
// Authorization 헤더 검증
// Authorization 헤더가 비어있거나 "Bearer " 로 시작하지 않은 경우
if(authorization == null || !authorization.startsWith("Bearer ")){
System.out.println("token null");
// 토큰이 유효하지 않으므로 request와 response를 다음 필터로 넘겨줌
filterChain.doFilter(request, response);
// 메서드 종료
return;
}
// Authorization에서 Bearer 접두사 제거
String token = authorization.split(" ")[1];
// token 소멸 시간 검증
// 유효기간이 만료한 경우
if(jwtUtil.isExpired(token)){
System.out.println("token expired");
filterChain.doFilter(request, response);
// 메서드 종료
return;
}
// 최종적으로 token 검증 완료 => 일시적인 session 생성
// session에 user 정보 설정
String loginId = jwtUtil.getLoginId(token);
String role = jwtUtil.getRole(token);
Member member = new Member();
member.setLoginId(loginId);
// 매번 요청마다 DB 조회해서 password 초기화 할 필요 x => 정확한 비밀번호 넣을 필요 없음
// 따라서 임시 비밀번호 설정!
member.setPassword("임시 비밀번호");
member.setRole(role);
// UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(member);
// 스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
// 세션에 사용자 등록 => 일시적으로 user 세션 생성
SecurityContextHolder.getContext().setAuthentication(authToken);
// 다음 필터로 request, response 넘겨줌
filterChain.doFilter(request, response);
}
}
7-6. SecurityConfig ( + BCryptPasswordEncoder )
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration configuration;
private final JWTUtil jwtUtil;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
// 시큐리티 필터 메서드
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// 스프링 시큐리티 jwt 로그인 설정
// csrf disable 설정
http
.csrf((auth) -> auth.disable());
// 폼로그인 형식 disable 설정 => POSTMAN으로 검증할 것임!
http
.formLogin((auth) -> auth.disable());
// http basic 인증 방식 disable 설정
http
.httpBasic((auth -> auth.disable()));
// 경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/jwt-login", "/jwt-login/", "/jwt-login/login", "/jwt-login/join").permitAll()
.requestMatchers("/jwt-login/admin").hasRole("ADMIN")
.anyRequest().authenticated()
);
// 세션 설정
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 새로 만든 로그인 필터를 원래의 (UsernamePasswordAuthenticationFilter)의 자리에 넣음
http
.addFilterAt(new LoginFilter(authenticationManager(configuration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
// 로그인 필터 이전에 JWTFilter를 넣음
http
.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
return http.build();
}
// BCrypt password encoder를 리턴하는 메서드
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
- JWT를 통한 인증, 인가 작업을 위해서는 세션을 무상태 (STATELESS) 로 설정하는 것이 중요!
7-7. JwtLoginController 생성
@RestController
@RequiredArgsConstructor
@RequestMapping("/jwt-login")
public class JwtLoginController {
private final MemberService memberService;
private final JWTUtil jwtUtil;
@GetMapping(value = {"", "/"})
public String home(Model model) {
model.addAttribute("loginType", "jwt-login");
model.addAttribute("pageName", "스프링 시큐리티 JWT 로그인");
String loginId = SecurityContextHolder.getContext().getAuthentication().getName();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iter = authorities.iterator();
GrantedAuthority auth = iter.next();
String role = auth.getAuthority();
Member loginMember = memberService.getLoginMemberByLoginId(loginId);
if (loginMember != null) {
model.addAttribute("name", loginMember.getName());
}
return "home";
}
@GetMapping("/join")
public String joinPage(Model model) {
model.addAttribute("loginType", "jwt-login");
model.addAttribute("pageName", "스프링 시큐리티 JWT 로그인");
// 회원가입을 위해서 model 통해서 joinRequest 전달
model.addAttribute("joinRequest", new JoinRequest());
return "join";
}
@PostMapping("/join")
public String join(@Valid @ModelAttribute JoinRequest joinRequest,
BindingResult bindingResult, Model model) {
model.addAttribute("loginType", "jwt-login");
model.addAttribute("pageName", "스프링 시큐리티 JWT 로그인");
// ID 중복 여부 확인
if (memberService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
return "ID가 존재합니다.";
}
// 비밀번호 = 비밀번호 체크 여부 확인
if (!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
return "비밀번호가 일치하지 않습니다.";
}
// 에러가 존재하지 않을 시 joinRequest 통해서 회원가입 완료
memberService.securityJoin(joinRequest);
// 회원가입 시 홈 화면으로 이동
return "redirect:/jwt-login";
}
@PostMapping("/login")
public String login(@RequestBody LoginRequest loginRequest){
Member member = memberService.login(loginRequest);
if(member==null){
return "ID 또는 비밀번호가 일치하지 않습니다!";
}
String token = jwtUtil.createJwt(member.getLoginId(), member.getRole(), 1000 * 60 * 60L);
return token;
}
@GetMapping("/info")
public String memberInfo(Authentication auth, Model model) {
Member loginMember = memberService.getLoginMemberByLoginId(auth.getName());
return "ID : " + loginMember.getLoginId() + "\n이름 : " + loginMember.getName() + "\nrole : " + loginMember.getRole();
}
@GetMapping("/admin")
public String adminPage(Model model) {
return "인가 성공!";
}
}
- @RestController ( = @Controller + @ResponseBody ) 를 통해서 JSON 형태로 데이터 반환
8. 실행 결과
- 폼 로그인을 disable 설정했기 때문에 POSTMAN을 통해서 POST 요청을 보내서 결과 확인
- POSTMAN 다운로드 링크 ( https://www.postman.com/downloads/ )
8-1. 홈 화면
8-2. 회원가입 화면 (GET 방식)
8-3. 회원가입 화면 (POST 방식)
- 비밀번호 일치 x
- loginId 중복
- 회원가입 완료
8-4. 로그인 화면 (POST 방식)
- 토큰을 발급해줌
- 발급한 토큰을 복사해서 Headers > Authorization에 추가할 것
- 이때 Authorization의 값에 "Bearer " + 복사한 토큰 값 으로 설정
8-5. Info 화면 (GET 방식)
8-6. Admin 화면 (GET 방식)
- 인가 성공 (ADMIN 계정 로그인 시)
- 인가 실패 (USER 계정으로 로그인 시) => 403 Forbidden 에러 발생
'Spring Boot' 카테고리의 다른 글
[Spring Boot] 로그인 기능 구현 (6) - 카카오 로그인 (OAuth 2.0) (0) | 2024.01.14 |
---|---|
[Spring Boot] 로그인 기능 구현 (5) - 구글 로그인 (OAuth 2.0) (0) | 2024.01.14 |
[Spring Boot] 스프링으로 엑셀 파일 읽기 (1) | 2024.01.08 |
[Spring Boot] 로그인 기능 구현 (3) - 스프링 시큐리티 로그인 (4) | 2024.01.07 |
[Spring Boot] 로그인 기능 구현 (2) - 세션 로그인 (0) | 2024.01.07 |