Spring Security
인증과 권한 관리를 용이하게 해주는 라이브러리
build.gradle에서 Spring Security의 주석을 해제해보자. 이제 그 어떤 페이지로도 들어갈 수 없다. Security에서 해당 주소로 오는 주소는 모두 /login으로 보내기 때문이다.
여기서 로그인을 하면 들어갈 수는 있지만… 우리가 기껏 만들어 놓은 로그인 페이지가 있는데, 그걸 활용해야지 무슨 듣도 보도 못한 폼에다가 로그인 하라는 걸까…?
그래서 Spring Security의 설정을 조금 변경해 주었다.
@Override
protected void configure(HttpSecurity http) throws Exception {
// csrf 토큰 비활성화
http.csrf().disable();
// 일부 페이지를 제외한 모든 사이트는 로그인 후 접속 가능.
http.authorizeRequests()
.antMatchers("/", "/login", "/join", "/js/**", "/image/**").permitAll()
.anyRequest().authenticated();
// 로그인 폼 커스텀
http.formLogin()
.loginPage("/")
.loginProcessingUrl("/auth/loginProc")
.defaultSuccessUrl("/home/index");
}
}
BCryptEncoder을 Bean으로 등록해 뒀으니 LoginService에서 회원가입을 처리할 때, 패스워드는 암호화해서 가입시키면 된다. 따로 코드는 안 올려도 되겠죠?
설정해둔 로그인 url로 요청이 날아오면, 그 요청을 가로채서 Security에서 로그인을 진행한 후 Security ContextHolder이라는 Security만의 session에 등록시킨다!
여기서 ContextHolder에 담길 수 있는 객체의 타입은 Authentication 타입이어야 하며, 여기에는 우리가 세션에 등록할 principal의 Account 객체가 담겨 있어야할 것. 그런데 문제는 ContextHolder에는 UserDetails 객체밖에 담지 못한다.
즉, UserDetails를 implements한 (Account를 매개변수로 받는)PrincipalDetails 클래스를 만들어 Authentication에 담은 후, 세션에 등록하면 된다!
그럼 결국 ContextHolder에는 내가 등록할 Principal이 등록되겠지?!
package com.kimdev.kimstagram.controller.api.auth;
import com.kimdev.kimstagram.model.Account;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class PrincipalDetail implements UserDetails {
Account account; // 로그인을 시도하는 Account
public PrincipalDetail(Account account) {
this.account = account;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collectors = new ArrayList<>();
collectors.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return String.valueOf(account.getRole());
}
});
return collectors;
};
@Override
public String getPassword() {
return account.getPassword();
}
@Override
public String getUsername() {
return account.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
이렇게 하면 Spring Security로 로그인을 구현할 수 있다.
그런데 formLogin()으로 로그인을 구현하면, ajax로는 데이터를 제대로 주고받을 수 없다! 왜냐하면 ajax로는 주로 json을 주고받는데… formLogin()은 기본적으로 json을 주고받는 형태가 아니기 때문!
사실 여기서 되게 많이 헤맸다... 근 이틀 정도는 헤맨 것 같은데...?
시큐리티의 formLogin을 사용하려면 json을 거의 포기해야 하는 수준이었는데, 일단은 JWT를 적용하기로 해봤다.
JWT (Json Web Token)
username이나 password 등을 담은 데이터(Signature)를 Secret key로 암호화하여 전송.
기존에는 세션 방식의 로그인을 사용했었다.
- 로그인을 시도한다.
- username과 password가 맞다면, 로그인을 성공시킨 후 자신(principal)을 서버와 연결시킨다(세션 등록)
- 즉 세션 방식은 서버와 클라이언트가 연결된 상태로 지속된다. → Stateful
그러나 이제는 세션으로 등록하는 것이 아닌, JWT 토큰을 사용하여 서버와 클라이언트를 통신시킬 것이다.
- 클라이언트에서 로그인을 시도하면, 서버는 username과 password를 보고 JWT토큰을 응답해준다.
- 클라이언트는 발급 받은 JWT 토큰을 저장해 뒀다가, 매 요청 시마다 토큰을 함께 보내준다.
- 서버는 클라이언트에게서 요청이 도착하면, 토큰이 유효한지 판단하여 유효한 경우에만 작업을 처리한다.
- 즉 JWT 방식은 서버와 클라이언트가 연결되지 않고, 토큰만 가지고 세션을 파악한다 → Stateless
JWT 방식을 구현하기 위해 기존의 formLogin 방식을 disable해주었고, Stateless로 변경해 주었다.
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable();
이말은즉슨, 스프링 시큐리티의 로그인 로직을 우리가 다 구현해야 한다는 것을 의미한다.
그렇다면, 스프링 시큐리티의 로그인은 어떤 방식으로 흘러갈까?
- 설정한 로그인 url에 로그인 POST 요청이 오면, UsernamePasswordAuthenticationFilter가 작동한다.
여기서 username과 password를 통해 인증 후, 해당하는 Account를 찾아서 세션에 담는 것.
즉 우리는 이 필터를 Override하여 JWT토큰을 생성하면 될 것이다! - 생성한 JWT 토큰을 가지고 요청이 들어오면, BasicAuthenticationFilter가 작동해서 권한을 확인한다.
즉 만료된 토큰인지, 또는 권한이 없는 페이지로 요청하지 않은 것인지 필터링해준다!
결국 우리는 이 두 필터를 상속받아 커스텀 필터를 만든 후, 기존 Filter Chain에 연결시켜 주기만 하면 된다!
package com.kimdev.kimstagram.Security.filter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kimdev.kimstagram.controller.api.auth.PrincipalDetail;
import com.kimdev.kimstagram.model.Account;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.Date;
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
setFilterProcessesUrl("/auth/loginProc");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// username, password를 받는다.
try {
ObjectMapper om = new ObjectMapper(); // json데이터 파싱하기 위한 클래스
Account account = om.readValue(request.getInputStream(), Account.class); // request에 담긴 값을 읽어서 Account 객체로 파싱해줌.
// username과 password로 토큰을 만든다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(account.getUsername(), account.getPassword());
// 이 토큰을 가지고 로그인 시도!
// 이걸 실행할 때 loadUserByUsername()이 실행되는 것!
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 이걸 return해주면 이 객체가 session에 저장됨.
return authentication;
} catch (IOException e) {
e.printStackTrace();
}
return null;
//return super.attemptAuthentication(request, response);
}
// attemptAuthentication() 실행 후 인증이 정상적으로 되었다면 아래 함수가 실행된다.
// 여기서 JWT토큰을 만들어서, request를 요청한 사용자에게 response해주면 됨!
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetail principalDetail = (PrincipalDetail) authResult.getPrincipal();
// Hash 암호 방식으로 JWT토큰 생성 (RSA방식은 아님)
String jwtToken = JWT.create()
.withSubject("JWT_TOKEN")
.withExpiresAt(new Date(System.currentTimeMillis() + (60000*10))) // 토큰의 만료 시간 (10분)
.withClaim("id", principalDetail.getAccount().getId()) // 비공개 클레임인데 그냥 값 암거나 넣으면 될 듯?
.withClaim("username", principalDetail.getAccount().getUsername())
.sign(Algorithm.HMAC512("kimdevAuth")); // 토큰에 붙일 고유한 Secret 값
// 만든 JWT토큰을 헤더에 붙여서 응답
response.addHeader("Authorization", "Bearer "+jwtToken);
}
}
package com.kimdev.kimstagram.Security.filter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.kimdev.kimstagram.Repository.AccountRepository;
import com.kimdev.kimstagram.controller.api.auth.PrincipalDetail;
import com.kimdev.kimstagram.model.Account;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
//
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private AccountRepository accountRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, AccountRepository accountRepository) {
super(authenticationManager);
this.accountRepository = accountRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// Jwt토큰 검증 -> 정상적인 사용자인지 확인한다
// 헤더가 있는지 확인
String jwtHeader = request.getHeader("Authorization");
if (jwtHeader == null || !jwtHeader.startsWith("Bearer")) { // 비정상적인 토큰인 경우
chain.doFilter(request, response); // 다시 필터를 타도록 체인에 넘김
return;
}
// Jwt토큰 검증
String jwtToken = request.getHeader("Authorization").replace("Bearer ", ""); // prefix는 빠지고 토큰 부분만 token에 담김.
String username = JWT
.require(Algorithm.HMAC512("kimdevAuth")) // 암호화 방식과 Secret 키
.build().verify(jwtToken) // 토큰 서명
.getClaim("username").asString(); // 토큰에서 username을 가져온 후 String으로 캐스팅
// username이 잘 들어왔다는 것은, 서명이 정상적으로 됐다는 뜻.
if (username != null) {
Account accountEntity = accountRepository.findByUsername(username).get();
PrincipalDetail principalDetail = new PrincipalDetail(accountEntity);
// 이미 서명된 토큰을 통해 username이 있다는 걸 확인했으니, 그냥 강제로 Authentication 객체를 만들어도 됨.
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetail, null, principalDetail.getAuthorities());
// 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
}
이렇게 두 필터를 만든 후, filterChain에 걸어 주었다.
http.addFilter(corsFilter)
.addFilter(new JwtAuthenticationFilter(authenticationManager())) // WebSecurityConfiguererAdapter가 갖고 있어서 그냥 넘겨주면 된다.
.addFilter(new JwtAuthorizationFilter(authenticationManager(), accountRepository));
이제 인증과 권한 필터의 구성은 모두 끝났다.
로그인을 시도하면 JwtAuthenticationFilter에서 username과 password를 가지고 10분간 유효한 JWT토큰을 만들어서 응답해준다.
이제 클라이언트는 매 요청 시마다 이 JWT 토큰을 헤더에 담아 서버로 보내며, 서버는 JwtAuthorizationFilter을 작동시켜 JWT 토큰을 받으면 해당 토큰이 유효한지, 권한이 부족하지는 않은지 확인한다.
아니 그런데 이렇게나 복잡했나?
기존에 세션 방식에서는 이런 로직이 필요 없었는데...
ㄴㄴㄴ 세션은 상대적으로 쉽게 값을 구할 수 있으니 구현이 쉬웠던 거고, JWT토큰은 토큰 자체가 암호화되어 있으니 상대적으로 조금 복잡하게 느껴지는 것 뿐!
이제 JWT 토큰을 만들고, 검사하는 로직까지 작성했다.
그럼 로그인 시에 어떻게 JWT 토큰을 받고 요청할 수 있을까?
나는 ajax로 username과 password를 body에 담아 POST 요청을 보내서 로그인하는 방식을 사용했다(이것밖에 못 함)
- /auth/loginProc으로 로그인 요청이 전송되면 해당 username과 password를 통해 JWT 토큰을 만든 후 반환해준다.
- js에서 해당 토큰을 localStorage에 저장한다!
(굳이 localStorage가 아니더라도 쿠키나 DB 등에 저장할 수도 있다) - 로그인이 완료되면 /index로 들어간다.
이 때 index의 헤더에는 checkToken() 함수가 작동되게 구현되어 있다. - checkToken() 함수는 /checkToken 경로로 JWT토큰을 담아 GET요청을 보낸다.
/checkToken은 아까 권한을 설정한 페이지이므로, 이 페이지로 GET요청이 들어오면 자동으로 JwtAuthorizationFilter가 작동하여 토큰이 유효한지 검사한다. - 토큰이 유효하지 않다면 다시 로그인 페이지("/")로 이동시킨다.
내가 작성한 알고리즘은 대략 위와 같다.
코드는 깃헙에 있으니까, 작성한 checkToken만 간략히 블로그에 올려보겠읍니다.
let checkToken = {
init: function() {
document.addEventListener('DOMContentLoaded', checkToken.check);
},
check: function() {
$.ajax({
type: "GET",
url: "/checkToken",
headers: {'Authorization':localStorage.getItem('Authorization')},
contentType: "application/json; charset=utf-8",
dataType: "html"
}).done(function(resp) {
if (resp != "Token_Success") {
location.href = "/";
}
}).fail(function (resp) { // 토큰이 만료된 경우
localStorage.removeItem('Authorization');
location.href = "/";
});
}
}
checkToken.init();
'프로젝트 > Kimstagram' 카테고리의 다른 글
[내멋대로 만드는 Kimstagram] 6. 게시글 화면 구현과 댓글 작성하기 (0) | 2024.01.21 |
---|---|
[내멋대로 만드는 Kimstagram] 5. 프로필 화면 만들기 (0) | 2024.01.21 |
[내멋대로 만드는 Kimstagram] 4. 글쓰기 구현하기 (0) | 2024.01.16 |
[내멋대로 만드는 Kimstagram] 2. 로그인, 회원가입 구현하기 (0) | 2024.01.07 |
[내멋대로 만드는 Kimstagram] 1. 준비물 세팅 (2) | 2024.01.07 |