요약
기존에는 Controller 에서 form 데이터를 받고 DB 에 회원정보를 조회하여 세션연결을 하는 방식이었다.
이번 개선을 통해 세션 방식에서 JWT 방식으로 변경하고 Spring Security 와 Filter 를 사용하는 방식으로 변경한다.
목차
- Spring Security 세팅
- Login Filter 작성 part 1
- JWT 발급 작성
- Login Filter 작성 part 2
- JWT 인증 작성
개요
기존의 로그인 방식은 세션 로그인 방식이며 DB 에 패스워드를 암호화 하지 않은 상태로 저장한다.
세션로그인을 JWT 방식으로 변경하고 패스워드 암호화도 같이 적용한다.
Spring security 및 filter 를 사용해 JWT 를 구현한다.
Spring Security 및 JWT 라이브러리 추가
1
2
3
4
5
6
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
Spring Config 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
기본적으로 로그인(Athentication) 할 때 필요한 Bean 두 개를 작성해준다.
Login Filter 에서 사용할 AuthenticationManager 와 비밀번호를 암호화할 때 사용할 BCryptPasswordEncoder 를 등록해줬다.
Security Filter Chain 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 1. csrf 비활성화
http.csrf(auth -> auth.disable());
// 2. form 로그인 비활성화
http.formLogin((auth) -> auth.disable());
// 3. http basic 인증 방식 disable
http.httpBasic((auth) -> auth.disable());
// 4. 요청 경로별 인가 작업
// login, 회원가입, 아이디 찾기, 비밀번호 찾기는 인증없이 통과할 수 있도록 설정한다.
http.authorizeHttpRequests((auth) -> auth
.requestMatchers( "/", "/login", "/register", "findId", "findPw").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated());
// 5. session 을 stateless 로 설정
http.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
Servlet Filter Chain 에 등록할 Security Filter Chain 을 작성해 Bean 으로 등록한다.
기본적인 세팅만 한다.
회원 정보 Entity 수정
기존 Entity
| member_id | login_id | password | name | question | answer | |
|---|---|---|---|---|---|---|
수정 후 Entity
| member_id | login_id | password | name | question | answer | role | account_status | account_expire | password_expire | account_disable_reason | |
|---|---|---|---|---|---|---|---|---|---|---|---|
기존 member Entity 에 role, account_status, account_expire, password_expire, account_disable_reason 를 추가한다.
- role : admin, user
- account_status : active, locked, disabled
- account_expire : 계정 만료 일자. 오래 접속 안하면 휴면 계정 처리
- password_expire : 비밀번호 만료 일자. 3개월 이상 변경하지 않으면 로그인 불가
- account_disable_reason : 계정이 active 상태가 아닌 사유 작성 (이메일 인증을 하지 않았다, 휴면계정 등)
별도의 Entity 로 작성해야 싶었지만 기존의 Entity 에 포함시키기로 했다.
- 별도의 Entity 로 작성한다면 어떤 기준으로 나눌지 애매했다.
- email, name, question, answer 을 제외한 속성들은 로그인 인증시 반드시 같이 사용되기 때문에 Entity 를 나눴다가 다시 JOIN 할 이유가 없다 생각했다.
회원가입 로직 수정
1
2
3
4
5
6
7
8
9
10
11
// Before (코드 수정 전)
public boolean signUp(Member member) {
boolean isExist = memberRepository.existsByLoginIdOrEmail(member.getLoginId(), member.getEmail());
if(isExist) {
System.out.println("exist");
return false;
}
memberRepository.save(member);
return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// After (코드 수정 후)
public boolean signUp(Member member) {
boolean isExist = memberRepository.existsByLoginIdOrEmail(member.getLoginId(), member.getEmail());
if(isExist) {
System.out.println("exist");
return false;
}
member.setRole("USER");
member.setAccountStatus("active");
member.setAccountExpire(LocalDateTime.now().plusYears(3));
member.setPasswordExpire(LocalDateTime.now().plusMonths(3));
memberRepository.save(member);
return true;
}
수정한 Entity 에 맞게 회원가입 로직을 수정했다.(role, status(active), expire).
Login Filter 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public LoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// request 에서 username 과 password 를 꺼내 AuthenticationManager 에게 넘겨준다.
// Authentication Manager 는 적절한 Provider 를 선택하여 인증을 하도록 넘겨준다.
// Provider 는 받은 값을 UserDetailsService 의 loadUserByUsername 의 리턴값과 비교한다.
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// authenticationManager.authenticate(authToken); 인증에 성공하면 호출된다.
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
// authenticationManager.authenticate(authToken); 인증에 실패하면 호출된다.
}
}
Login Filter 의 기본적인 뼈대를 구성했다.
사용자가 입력한 username 과 password 를 Authentication Provider 에게 넘기는 로직까지 작성했다.
Login Filter 등록
1
2
3
4
5
6
7
8
9
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 생략
// 6. Filter 등록
http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
원래 UsernamePasswordAuthenticationFilter 가 있던 자리에 내가 만든 Login Filter 를 등록한다.
UserDetailsService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
public UserDetailsServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> byLoginId = memberRepository.findByLoginId(username);
// 조회된 member 가 있다.
if(byLoginId.isPresent()) {
Member member = byLoginId.get();
return new MemberDetails(member);
}
// 조회된 member 가 없다.
return null;
}
}
Login Filter 의 authenticationManager.authenticate(authToken); 가 수행되면 Provider 로 값이 전달된다.
Provider 는 DB 에 저장된 값과 비교하기 위해 UserDetailsService 의 loadUserByUsername() 를 호출한다.
따라서 loadUserByUsername() 를 구현해준다.
UserDetails
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class MemberDetails implements UserDetails {
private final Member member;
public MemberDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return member.getRole();
}
});
return collection;
}
public Long getMemberId() {
return member.getMemberId();
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getLoginId();
}
@Override
public boolean isAccountNonExpired() {
// true : 만료되지 않음
// false : 만료됨
LocalDateTime today = LocalDateTime.now();
LocalDateTime accountExpire = member.getAccountExpire();
if(today.isBefore(accountExpire))
return true;
return false;
}
@Override
public boolean isAccountNonLocked() {
// true : lock 아님
// false : locked
String accountStatus = member.getAccountStatus();
if(accountStatus.equals("locked"))
return false;
return true;
}
@Override
public boolean isCredentialsNonExpired() {
// true : 만료되지 않음
// false : 만료됨
LocalDateTime today = LocalDateTime.now();
LocalDateTime passwordExpire = member.getPasswordExpire();
if(today.isBefore(passwordExpire))
return true;
return false;
}
@Override
public boolean isEnabled() {
// true : 활성화 상태
// false : 비활성화 상태
String accountStatus = member.getAccountStatus();
if(accountStatus.equals("active"))
return true;
else if(accountStatus.equals("disable"))
return false;
return false;
}
}
UserDetailsService 의 loadUserByUsername() return type 은 UserDetails 이므로 member 를 바로 provider 에게 전달할 수 없다.
member 를 UserDetails 로 만들어주는 DTO 를 만든다.
이제 Provider 가 client 가 보내준 값과 DB 에 보관된 값을 비교할 수 있게 되었다.
인증에 성공하면 successfulAuthentication() 가 호출된다.
참고로 getMemberId() 는 @Override 가 안붙은걸 알 수 있다.
이건 내가 필요해서 직접 정의한 메서드기 때문이다.
successfulAuthentication
1
2
3
4
5
6
7
8
9
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
// 생략
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 인증에 성공하면 호출된다.
// JWT 를 발급해 return 한다.
}
}
로그인 시도 -> Login Filter -> DB 에 저장된 값과 비교 하는 로직을 만들었다.
다음 차례는 성공 또는 실패시 수행할 동작을 작성한다.
successfulAuthentication() 를 구현하려고 보니 JWT 발급하는걸 만들지 않았다.
JWT 를 발급하는 로직을 작성하고 다시 와야한다.
JWT 발급 로직
1
2
3
4
5
6
7
8
dependencies {
// 생략
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
1
2
3
# application.properties 파일
spring.jwt.secret=${SPRING_JWT_SECRET}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Component
public class JWTUtil {
private SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getMemberId(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("memberId", String.class);
}
/**
* jwt token 검증 후 username 을 꺼낸다.
* @param token
* @return
*/
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
/**
* jwt token 을 검증 후 role 을 꺼낸다.
* @param token
* @return
*/
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
/**
* jwt token 을 검증 후 만료가 됐는지 확인한다.
* @param token
* @return
*/
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
/**
* JWT token 생성한다.
* @param username
* @param role
* @param expiredMs
* @return
*/
public String createJwt(Long memberId, String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("memberId", String.valueOf(memberId))
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
// 생략
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
// 인증에 성공하면 호출된다.
// JWT 를 발급해주자.
// authentication.getPrincipal() 은 user details 을 반환한다. Type 은 Object 형태로 반환된다.
MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal();
Long memberId = memberDetails.getMemberId();
String username = memberDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = memberDetails.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator(); // 굳이 Iterator 에 보관하지 않고 authorities.iterator().next() 로 축약가능.
GrantedAuthority auth = iterator.next(); // collection 의 첫번째 값이 auth 에 보관된다.
String role = auth.getAuthority();
String token = jwtUtil.createJwt(memberId, username, role, 60*60*10L*100); // 3600초
// response.addHeader("Authorization", "Bearer " + token);
Cookie cookie = new Cookie("Authorization", token);
cookie.setMaxAge(3600);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
// 인증에 실패하면 호출된다.
System.out.println("로그인 실패"); // 안해도 됨
response.setStatus(401);
}
}
build.gradle 에 jwt 라이브러리를 추가한다.
application.properties 에 spring.jwt.secret=${SPRING_JWT_SECRET} 를 추가한다. 환경변수가 아니라 값을 직접 넣어도 된다.
successfulAuthentication() 메서드 에 JWT 를 생성 후 response 헤더에 추가해주는 로직을 작성했다.
refresh token 을 구현하지 않아서 페이지 이동시 JWT 가 사라진다.
그래서 Cookie 에 보관하여 60분동안 JWT 가 유지되도록했다.
JWT 인증 로직
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
public JWTFilter(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. JWT 를 검증한다.
// 1-1. request 헤더에서 JWT 를 꺼낸다.
// 1-2. JWT 형식이 맞는지 검사한다.
// 1-3. 유효한 JWT 인지 검증한다.
// String authorization = request.getHeader("Authorization");
// 여기부터
String authorization = null;
if (request.getCookies() == null) {
filterChain.doFilter(request,response);
return;
}
for (Cookie cookie : request.getCookies()) {
if(cookie.getName().equals("Authorization")) {
authorization = "Bearer " + cookie.getValue();
}
} // 여기까지는 Cookie 에 저장된 JWT 를 꺼내기 위한 방법이다.
if(authorization == null || !authorization.startsWith("Bearer")) {
System.out.println("Token 이 없거나 잘못된 형식입니다. : " + authorization);
filterChain.doFilter(request,response);
return ;
}
String token = authorization.split(" ")[1];
try {
jwtUtil.isExpired(token);
} catch (ExpiredJwtException e) {
System.out.println("Token 이 만료되었습니다.");
filterChain.doFilter(request,response);
return;
}
String memberId = jwtUtil.getMemberId(token);
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
// 2. 인증 정보를 보관한다.
Member member = new Member();
member.setMemberId(Long.parseLong(memberId));
member.setLoginId(username);
member.setPassword("temp");
member.setRole(role);
MemberDetails memberDetails = new MemberDetails(member);
Authentication authToken = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
1
2
3
4
5
// 6. Filter 등록
http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
발급받은 JWT 로 서버에 요청을 보낼때, JWT 를 검증하는 로직을 작성한다.
기존의 로직을 수정하지 않고 Cookie 에서 꺼내는 부분만 추가하다 보니까 “Bearer” 를 붙이는 과정이 생겨버렸다.
일단 그냥 넘어가자. 일단 임시로 해놓고 나중에 바꿀거다..
작성 후 SecurityConfig 에 Filter 를 등록해준다.
마무리 단계
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Before
// 로그인 인증
@PostMapping("/authentication")
@ResponseBody
public Map<String, String> authentication(@RequestBody LoginForm form, HttpSession session) {
Map<String, String> response = new HashMap<>();
System.out.println(form.toString());
Member member = new Member();
member.setLoginId(form.getLoginId());
member.setPassword(form.getPassword());
member = memberService.athentication(member);
if (member != null) {
// 로그인 성공. 메인 페이지로 이동
session.setAttribute("memberId", member.getMemberId());
session.setMaxInactiveInterval(1800); // 30분간 세션 유지
// response.put("memberId", member.getMemberId().toString());
response.put("result", "success");
response.put("message", "/chatting_room_list");
}
else {
// 로그인 실패. 로그인 페이지로 이동
response.put("result", "fail");
response.put("message", "로그인 실패\n 아이디 또는 비밀번호를 확인해주세요" );
}
return response;
}
@GetMapping("/chatting_room_list")
public String chattingRoomList(HttpSession session) {
if(session.getAttribute("memberId") == null) {
// 로그인하지 않고 주소창에 /chatting_room_list 를 입력하고 넘어오면
// 로그인 창으로 돌려보낸다.
return "login";
}
return "chatting_room_list";
}
1
2
3
4
5
6
7
8
// After
// authentication 메서드는 더 이상 필요없음. 삭제
@GetMapping("/chatting_room_list")
public String chattingRoomList() {
// 로그인 여부 검증은 JWT Filter 에서 이미 끝냈다.
return "chatting_room_list";
}
이제 다시 로그인부터 천천히 로직을 따라가면서 기존에 Session 로그인 방식들을 JWT 방식으로 코드를 수정하면 된다.
1
2
3
4
5
6
7
8
// 4. 요청 경로별 인가 작업
// login, 회원가입, 아이디 찾기, 비밀번호 찾기는 인증없이 통과할 수 있도록 설정한다.
http.authorizeHttpRequests((auth) -> auth
.requestMatchers( "/", "/login", "/register", "/findId", "/findPw").permitAll()
.requestMatchers( "login_page", "/register/check-duplicate", "/chatting_room_list").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
// .anyRequest().permitAll()); // 나머지 모든 경로에 대해 JWT 인증을 하지 않는다.
.anyRequest().authenticated()); // 나머지 모든 경로에 대해 JWT 인증을 수행한다.
로그인 로직을 따라가면서 jwt 인증이 필요없는 경로는 permitAll() 로 설정해주는걸 잊지말자.


