최근 취미 삼아 Spring Boot를 독학하고 있는데, DRF 개발 경험도 있고 전반적인 구조를 파악하니 생각보다 쉽네 ㅎㅎ 라고 오만해져있었다.
그러다가 어제 유저 로그인/로그아웃과 권한 인증을 구현하다가 머리카락 죄다 쥐어뜯어서 탈모올 뻔 했다.
Spring Security가 거의 Spring에서 최종 끝판왕급으로 어려운 내용이라고 하는데, 진위여부를 떠나서 어려운 건 사실이다.
왜냐하면 동작하는 flow 좇는 것도 그렇고 처음 보는 interface가 쏟아져나오니 어디서 부터 잡아야할지 감이 안 오기 때문.
그리고 버전이 업데이트 되면서 구글링해서 찾은 내용을 전부 새롭게 써야 하는데, 정보가 너무 없다.
따라서 이번 포스팅은 Spring Security가 뭔지 간략히 다뤄보고 실제 구현한 내 코드가 어떻게 동작하는지 분석해보는 내용을 다룰 것이다.
당연히 잘못된 내용이 있을 것이고, 잘 모르는 부분에 대해선 이실직고 할 거라 혹시 답을 아시는 분은 댓글 좀 부탁드립니다..☺️
목차
1. What is SpringSecurity?
2. PasswordEncoder
3. Granted Authrity
4. UserDetail
5. userdetails.User
6. UserDetailService
7. AuthenticationProvider
8. SecurityFilterChain
9. WebSecurityConfigurer
10. 전체적인 Flow 정리
1. What is SpringSecurity?
Spring Framework에서 지원하는 라이브러리같은 건가 싶었는데 이것도 하나의 Framework였다.
기능을 한 줄 요약해보면 Java application 환경에서 Authentication과 Authorization 기능을 제공한다.
혹시 다른 프레임워크로 백엔드 개발을 해본 사람이라면 대충 어떻게 흘러갈지 알 수 있을 것이다.
request 요청을 보내면 중간에 Interceptor(번역하면 '낚아채다')를 이용해 바로 controller로 넘어가는 것이 아니라
개발자가 사전에 정의한 filter링을 통해 요청 및 응답을 참조 혹은 가공할 수 있다.
이 기능은 비단 SpringFramework 뿐만 아니라 정말 많은 곳에서 사용되는 개념이므로 필요하다면 추가적으로 찾아보길 권장한다.
정리가 잘 되어 있는 것 같아서 이 블로그를 추천.
🔐 Authentication(인증) & Authorization(인가)
인증은 사용자 정보를 DB에 등록된 데이터와 대조하여 본인이 맞는지를 판단하는 것이고
인가는 인증되어 있는 사용자가 일반 유저인지 관리자인지를 판단하여 서버에 요청하는 자원에 접근 가능한지를 판단한다.
예를 들어, 티스토리에 로그인 한 것은 인증을 밟는 절차이고 굳이 블로그장이 아니더라도 누구나 내 블로그를 읽을 수 있다. 하지만 댓글을 남기려면 내가 티스토리 회원이라는 권한을 부여받아야지 작성이 가능하다.
반면에 내 블로그의 글을 작성하고 수정하는 것은 단순히 티스토리 회원이라는 권한으로는 부족하다.
본인이 해당 블로그를 소유하고 있다는 인증 과정을 거쳐 발급받은 소유권이 인가되어야지 가능한 기능이라고 볼 수 있다.
🙋♂️ Principal(본인) & Credential(자격)
이게 사실 영어 단어 공부를 하는 느낌이긴 한데, 밑으로 내려가서 처음 보는 메서드가 남발해도 네이밍의 의도를 파악하면 얼추 이해할 수 있는 것들이 많다.
Principal은 현재 request를 보내 자원에 접근하려는 유저 정보를 보여주고, Credential은 자격이라는 뜻이긴 한데 요청을 보낸 유저의 비밀번호를 얻을 수 있다.
나도 좀 깔쌈하게 디버깅해서 정보를 얻어내고 싶었는데 안 되네 ㅎㅎ
그냥 나답게 콘솔창에 정보를 뿌려봤더니 이런 식으로 나왔다.
디버깅을 true로 해놓으면 request 요청을 보냈을 때 security filter chain이라는 정보가 나온다.
밑에서 부터 위의 순서로 해당 작업이 이루어질 수 있다는 걸 알 수 있으며,
제일 처음에 FilterSecurityInterceptor가 request 객체를 낚아챘음을 알 수 있다.
2. PasswordEncoder
package org.springframework.security.crypto.password;
/**
* Service interface for encoding passwords.
*
* The preferred implementation is {@code BCryptPasswordEncoder}.
*
* @author Keith Donald
*/
public interface PasswordEncoder {
/**
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
* greater hash combined with an 8-byte or greater randomly generated salt.
*/
String encode(CharSequence rawPassword);
/**
* Verify the encoded password obtained from storage matches the submitted raw
* password after it too is encoded. Returns true if the passwords match, false if
* they do not. The stored password itself is never decoded.
* @param rawPassword the raw password to encode and match
* @param encodedPassword the encoded password from storage to compare with
* @return true if the raw password, after encoding, matches the encoded password from
* storage
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* Returns true if the encoded password should be encoded again for better security,
* else false. The default implementation always returns false.
* @param encodedPassword the encoded password to check
* @return true if the encoded password should be encoded again for better security,
* else false.
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
Spring Scurity에서 비밀번호를 암화한 후에 저장하기 위해 인터페이스로 정의되어있다.
encode로 입력받은 정보를 암호화하고, matches 메서드로 입력 패스워드와 저장된 패스워드를 비교할 수 있다.
하지만 우리는 PasswordEncoder를 구현해야할 필요는 없다. 이미 구현된 다른 객체들을 사용하면 된다.
Deprecated된 구현체들을 제외하고 몇 가지가 더 있긴 한데 나는 공부용으로 BCryptPasswordEncoder밖에 사용 안 해봐서 다른 건 잘 모르겠다.
중요한 건 인터페이스와 구현체가 모두 존재하기 때문에 사용하기 위해 WebSecurityConfig 소스에 Bean으로 등록해주면 알아서 잘 매핑되어 작동된다는 것만 알고 넘어가면 된다.
더 찾아보니 각자 암호화를 위해 사용하는 알고리즘이 다르다고 한다.
BCryptPasswordEncoder의 경우 보안력을 높이기 위해 의도적으로 느린 속도로 작동하고 다른 것들도 구현하려는 서비스에 따라 맞춰서 사용하면 되는 것 같다.
보통 BCryptPasswordEncoder, Argon2PasswordEncoder, Pbkdf2PasswordEncoder, SCryptPasswordEncoder를 사용하는데 내가 무슨 보안 공부하려고 쓰는 포스팅은 아니니 궁금한 건 따로 찾아보면 된다.
✒️ PasswordEncoder는 encoding을 할 때마다 다른 password가 생성된다?
포스팅을 쓰다가 호기심에 BCryptPasswordEncoder 소스를 조금 읽어봤는데 salt라는 것을 호출하고 있었다.
public class BCryptPasswordEncoder implements PasswordEncoder {
(...)
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
private String getSalt() {
if (this.random != null) {
return BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
}
return BCrypt.gensalt(this.version.getVersion(), this.strength);
}
(...)
}
조금 더 PasswordEncoder에 대한 상세한 내용을 뒤져보니 비밀번호를 암호화가 단방향으로 진행된다.
이렇게 되면 해당 분야를 어느정도 파고들어 공부 좀 해보면, 솔직히 복호화가 어려울 순 있어도 불가능할 정도는 아니라는 의문이 든다.
DRF를 쓸 때는 복호화를 위해 별의 별 문자열을 다 가져다 붙이던데 Spring은 그 정도도 안 하나? 싶지만 하고 있었다.
바로 salt를 랜덤으로 생성하여 입력된 password에 갖다 붙인 후에 해시된 값을 저장한다.
3. GrantedAuthority
현재 사용자가 가지는 권한을 말한다.
SpringSecurity가 이해하도록 하기 위해서는 "ROLE_"를 접두사로 붙여야 하는데,
ROLE_ADMIN, ROLE_USER 처럼 쓰면 된다.
근데 난 이렇게 안 했는데 작동이 되고 있어서 다소 혼란스러울 예정.
public final class SimpleGrantedAuthority implements GrantedAuthority {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final String role;
public SimpleGrantedAuthority(String role) {
Assert.hasText(role, "A granted authority textual representation is required");
this.role = role;
}
@Override
public String getAuthority() {
return this.role;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj instanceof SimpleGrantedAuthority) {
return this.role.equals(((SimpleGrantedAuthority) obj).role);
}
return false;
}
@Override
public int hashCode() {
return this.role.hashCode();
}
@Override
public String toString() {
return this.role;
}
}
보통 SimpleGrantedAuthority를 이용해서 roles list를 가져온다.
4. UserDetail
public interface UserDetails extends Serializable {
/**
* Returns the authorities granted to the user. Cannot return <code>null</code>.
* @return the authorities, sorted by natural key (never <code>null</code>)
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* Returns the password used to authenticate the user.
* @return the password
*/
String getPassword();
/**
* Returns the username used to authenticate the user. Cannot return
* <code>null</code>.
* @return the username (never <code>null</code>)
*/
String getUsername();
/**
* Indicates whether the user's account has expired. An expired account cannot be
* authenticated.
* @return <code>true</code> if the user's account is valid (ie non-expired),
* <code>false</code> if no longer valid (ie expired)
*/
boolean isAccountNonExpired();
/**
* Indicates whether the user is locked or unlocked. A locked user cannot be
* authenticated.
* @return <code>true</code> if the user is not locked, <code>false</code> otherwise
*/
boolean isAccountNonLocked();
/**
* Indicates whether the user's credentials (password) has expired. Expired
* credentials prevent authentication.
* @return <code>true</code> if the user's credentials are valid (ie non-expired),
* <code>false</code> if no longer valid (ie expired)
*/
boolean isCredentialsNonExpired();
/**
* Indicates whether the user is enabled or disabled. A disabled user cannot be
* authenticated.
* @return <code>true</code> if the user is enabled, <code>false</code> otherwise
*/
boolean isEnabled();
}
우선 Spring Security가 정의하는 User 객체를 이해해야 진행이 된다.
이후에 UserDetails 인터페이스를 구현한 클래스를 사용해야 하는데 Spring Security가 클라이언트를 인지하기 위한 수단이라 어쩔 수가 없다.
정의된 메서드를 순서대로 정리하면 다음 표와 같다.
Method | return value | notion |
getAuthorities() | Collection<? extends GrantedAuthority> | 유저의 권한 목록 정보 |
getPassword() | String | 유저의 패스워드 |
getUsername() | String | 유저의 이름 (나중에 기준 바꿀 수 있음) |
isAccountNonExpired() | boolean | 계정 만료 여부 |
isAccountNonLocked() | boolean | 계정 잠김 여부 |
isCredentialsNonExpired() | boolean | 계정 패스워드 만료 여부 |
isEnabled() | boolean | 계정 사용 가능 여부 |
여기서 getUsername은 default로 username column을 가리키는데 이후에 커스텀하는 방법이 있다.
아니면 UserDetail을 상속받은 CustomUserDetail을 만들어서 메서드를 구현하는 방법도 있다.
하지만 이걸 알아야 하는 이유는 따로 있는데, UserDetail interface를 상속받는 다른 구현체들을 다루기 위함이다.
(굳이 커스텀할 것이 아니라면 보통 이미 구현된 User를 상속해서 쓴다.)
5. userdetails.User
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
/**
* Calls the more complex constructor with all boolean arguments set to {@code true}.
*/
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
/**
* Construct the <code>User</code> with the details required by
* {@link org.springframework.security.authentication.dao.DaoAuthenticationProvider}.
* @param username the username presented to the
* <code>DaoAuthenticationProvider</code>
* @param password the password that should be presented to the
* <code>DaoAuthenticationProvider</code>
* @param enabled set to <code>true</code> if the user is enabled
* @param accountNonExpired set to <code>true</code> if the account has not expired
* @param credentialsNonExpired set to <code>true</code> if the credentials have not
* expired
* @param accountNonLocked set to <code>true</code> if the account is not locked
* @param authorities the authorities that should be granted to the caller if they
* presented the correct username and password and the user is enabled. Not null.
* @throws IllegalArgumentException if a <code>null</code> value was passed either as
* a parameter or as an element in the <code>GrantedAuthority</code> collection
*/
public User(String username, String password, boolean enabled, boolean accountNonExpired,
boolean credentialsNonExpired, boolean accountNonLocked,
Collection<? extends GrantedAuthority> authorities) {
Assert.isTrue(username != null && !"".equals(username) && password != null,
"Cannot pass null or empty values to constructor");
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
}
(...)
}
UserDetails와 CredentialsContainer를 상속받은 Spring Security가 인지하는 User의 기본적인 형태라 보면 된다.
나는 간단한 로그인 기능만 쓸 생각이라 정의된 객체를 사용했지만, User 클래스를 살펴보면 username, password, authorities 정보밖에 담고 있지 않다는 것을 알 수 있다.
만약, 이메일이나 전화번호같은 정보까지 담고 싶으면 커스텀을 하면 되지만 나중에 외부 로그인 연동을 구현하고자 할 때 상당한 이슈가 발생할 수 있으니 충분한 경험도를 쌓기 전에는 별로 권장하고 싶지 않다.
6. UserDetailService
public interface UserDetailsService {
/**
* Locates the user based on the username. In the actual implementation, the search
* may possibly be case sensitive, or case insensitive depending on how the
* implementation instance is configured. In this case, the <code>UserDetails</code>
* object that comes back may have a username that is of a different case than what
* was actually requested..
* @param username the username identifying the user whose data is required.
* @return a fully populated user record (never <code>null</code>)
* @throws UsernameNotFoundException if the user could not be found or the user has no
* GrantedAuthority
*/
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
정말 단촐하기 그지없는 인터페이스지만 loadUserByUsername이라는 메서드가 중요하다.
나중에 해당 인터페이스를 끌고와서 CustomUserDetailService를 구현하면 username을 이용해 SpringSecurity로부터 매칭되는 유저 정보를 불러올 수 있다.
7. AuthenticationProvider
public interface AuthenticationProvider {
/**
* Performs authentication with the same contract as
* {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
* .
* @param authentication the authentication request object.
* @return a fully authenticated object including credentials. May return
* <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
* authentication of the passed <code>Authentication</code> object. In such a case,
* the next <code>AuthenticationProvider</code> that supports the presented
* <code>Authentication</code> class will be tried.
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication) throws AuthenticationException;
/**
* Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the
* indicated <Code>Authentication</code> object.
* <p>
* Returning <code>true</code> does not guarantee an
* <code>AuthenticationProvider</code> will be able to authenticate the presented
* instance of the <code>Authentication</code> class. It simply indicates it can
* support closer evaluation of it. An <code>AuthenticationProvider</code> can still
* return <code>null</code> from the {@link #authenticate(Authentication)} method to
* indicate another <code>AuthenticationProvider</code> should be tried.
* </p>
* <p>
* Selection of an <code>AuthenticationProvider</code> capable of performing
* authentication is conducted at runtime the <code>ProviderManager</code>.
* </p>
* @param authentication
* @return <code>true</code> if the implementation can more closely evaluate the
* <code>Authentication</code> class presented
*/
boolean supports(Class<?> authentication);
}
입력된 로그인 정보와 DB의 사용자 정보를 비교하는 인터페이스다.
authenticate() 메서드의 인자로 Authentication 객체가 있는데, Bean에 등록해놓으면 사용자 로그인 정보가 여기로 들어오게 된다.
그러면 위에서 봤었던 loadUserByUsername으로 DB에 저장된 유저 정보를 끌고와서 비교해준 후에, 유효하다면 인증되었다는 Token 정보인 Authentication 객체를 돌려주면 다음으로 넘어간다.
참고로 user의 id가 principal, password가 credential이라고 생각하면 된다.
8. SecurityFilterChain
1. Filter
Client로부터 받은 Http request&response를 개발자 의도대로 수정할 수 있는 객체.
말 그대로 클라이언트와 자원 사이에서 큰 비용없이 여러 가공 작업을 할 수 있게 된다.
2. SecurityFilterChain
뭐 또 얼마나 새로운 내용이 등장할까 싶지만 포스팅 가장 시작점에서 봤던 것이다.
Spring Security는 여러개의 Filter 객체를 마치 Chain으로 이어놓고 순차적으로 처리한다.
이 일련의 Filter들을 실행하는 주체가 있을텐데 FilterChainProxy라는 클래스다.
(참고로 이 내용을 당장 알 필요는 없다.)
public class FilterChainProxy extends GenericFilterBean {
private static final Log logger = LogFactory.getLog(FilterChainProxy.class);
private static final String FILTER_APPLIED = FilterChainProxy.class.getName().concat(".APPLIED");
private List<SecurityFilterChain> filterChains;
private FilterChainValidator filterChainValidator = new NullFilterChainValidator();
private HttpFirewall firewall = new StrictHttpFirewall();
(...)
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
if (!clearContext) {
doFilterInternal(request, response, chain);
return;
}
try {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
doFilterInternal(request, response, chain);
}
catch (Exception ex) {
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
Throwable requestRejectedException = this.throwableAnalyzer
.getFirstThrowableOfType(RequestRejectedException.class, causeChain);
if (!(requestRejectedException instanceof RequestRejectedException)) {
throw ex;
}
this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response,
(RequestRejectedException) requestRejectedException);
}
finally {
SecurityContextHolder.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
}
(...)
private List<Filter> getFilters(HttpServletRequest request) {
int count = 0;
for (SecurityFilterChain chain : this.filterChains) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Trying to match request against %s (%d/%d)", chain, ++count,
this.filterChains.size()));
}
if (chain.matches(request)) {
return chain.getFilters();
}
}
return null;
}
public List<Filter> getFilters(String url) {
return getFilters(this.firewall.getFirewalledRequest((new FilterInvocation(url, "GET").getRequest())));
}
(...)
}
DelegatingFilterProxy라는 sevlet filter가 FilterChainProxy를 호출하면 getFilters()가 수행되는 것이다.
좀 더 자세한 내용을 참고하고 싶다면 다음의 블로그를 참조하자.
이 내용을 굳이 언급한 이유는 외울 필요는 없지만 http 설정을 해줄 때, 참고해야할 사항들이 있다.
3. http method
① 접근 권한 설정
http.authorizeRequest()
.antMatcher("/", "/login").permitAll()
.anyRequest().authenticated()
Method | Notion |
authorizeRequests() | HttpServletRequest 요청 URL에 따라 접근 권한 지정 |
antMatchers(" 경로패턴 ") | request Url 지정 |
authenticated() | 인증된 유저만 접근 가능 |
hasAuthority() || hasAnyAuthority() | 특정 권한을 가진 유저만 접근 가능 |
permitAll() | 모든 유저가 접근 가능 |
anonymous() | 인증되지 않은 유저만 접근 가능 |
denyAll() | 모든 유저가 접근 불가 |
② 로그인 설정
http.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/")
.permitAll
Method | Notion |
formLogin() | form Login으로 로그인을 지정한다. |
loginPage(" 경로패턴 ") | default는 /login, Page를 커스텀하고 싶으면 설정 |
defaultSuccessUrl(" 경로패턴 ") | 로그인 성공 시, 이동할 페이지 등록 |
successHandler(AuthenticationSuccessHandler) | 로그인 성공 시, 실행할 로직을 객체로 상속받아 지정 |
③ 로그아웃 설정
http.logout()
.logoutSuccessUrl("/login")
Method | Notion |
logout() | logout 설정을 지정한다. |
logoutRequestMatcher(new AntPathRequestMatcher(" 경로패턴 ")) | default는 /logout, 변경하고 싶으면 logout 경로를 지정한다. |
logoutSuccessUrl(" 경로패턴 ") | 로그아웃 성공 시, 이동할 페이지 등록 |
invalidateHttpSession(true) | 로그아웃 성공 시, session 제거 |
9. WebSecurityConfig
예전에는 WebSecurityConfig를 사용하기 위해서 WebSecurityConfigurerAdapter를 상속받아서 오버라이딩 했었는데, spring security 5.7이상부터는 해당 기능 사용을 권장하지 않는다고 한다.
나름 괜찮아보여서 들고 왔는데 이건 또 Kotlin으로 써놨네. 아 ㅋㅋㅋㅋ 🤦♂️
결국 SpringConfig처럼 Bean을 등록시켜서 사용하라는 의미가 된다.
하지만 SpringBoot에서 이미 default로 SecurityFilterChain을 등록하고 있는데, 또 Bean으로 주입하려고 하면 스프링이 양다리 걸친다고 화낸다.
따라서 클래스 위에 Annotation을 추가해주어야 한다.
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
이제 내가 구현한 기능을 위한 모든 내용을 설명했다.
(엉망진창 굴러가는) 구현 코드를 확인해보자.
솔직히 왜 되고 있는 건지 아직도 잘 모르겠음.
10. 전체적인 Flow 정리
처음부터 위의 사진을 보면 대체 무슨 소리가 하고 싶은 걸까 싶지만 흐름을 잘 좇아오기만 했다면, 드디어 대략적인 흐름이 눈에 보일 것이다.
해당 flow가 적합하다고 가정했을 때, 우리가 구현해야하는 순서가 대략적으로 정해진다.
참고로 유저가 요청한 시점부터 보면 안 된다.
개발자는 역순으로 흐름을 좇아야 하므로 DB와 상호작용하는 부분부터 떠올려보자.
- 우선 DB User 정보를 담을 그릇이 필요하다. → UserContext extends User
- AuthenticationProvider을 통해 검증을 수행하려면 DB 정보를 꺼내와야 한다. → UserDetailService 구현체
- 입력받은 정보를 Security가 알아서 잘 받아줬다 치고 비교 및 인증 수행. → AuthenticationProvider
- SpringSecurity가 인지할 수 있어야 한다. → AuthenticationProvider을 Bean으로 등록
- SpringFilterChain을 이용하여 Client의 정보 가공 시작.
참고로 기본적인 user controller, repository, entity, service는 모두 구현되어 있다고 가정.
1. UserContext extends User
package Likelion.SpringStudy.config.security;
import Likelion.SpringStudy.domain.UserDomain;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
public class UserContext extends User {
private final UserDomain userDomain;
public UserContext(UserDomain userDomain, Collection<? extends GrantedAuthority> authorities) {
super(userDomain.getUsername(), userDomain.getPassword(), authorities);
this.userDomain = userDomain;
}
public UserDomain getUser() {
return userDomain;
}
}
우선 SpringSecurity의 User 객체를 상속받아서 값을 담아둘 수 있는 UserContext를 정의한다.
2. UserDetailService 구현체
package Likelion.SpringStudy.config.security;
import Likelion.SpringStudy.domain.UserDomain;
import Likelion.SpringStudy.repository.UserRepositoryInterface;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@AllArgsConstructor
@Service("userDetailsService") // bean 등록
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepositoryInterface userRepositoryInterface;
@Override
public UserDetails loadUserByUsername(String nickname) throws UsernameNotFoundException {
UserDomain userDomain = userRepositoryInterface.findByNickname(nickname).orElseThrow(() ->
new UsernameNotFoundException("해당 닉네임을 찾을 수 없습니다."));
List<GrantedAuthority> roles = new ArrayList<>();
roles.add(new SimpleGrantedAuthority(userDomain.getRole()));
return new UserContext(userDomain, roles);
}
}
그다음 CustomUserDetailService를 만들건데, 말 그대로 cusotm한 내용이라 @Service에 userDetailsService를 넣어서 Bean에 등록해주어야 한다.
이건 loadUserByUsername method를 이용하여 DB의 정보를 가져오기 위함이라고 이미 언급했었다.
존재한다면 미리 만들어둔 UserContext를 이용하여 데이터를 담아둔다.
참고로 나는 username이 아니라 nickname으로 로그인을 시도할 것이기 때문에 변수명을 username이 아닌 nickname으로 지었다.
이걸 바꾼다고 값이 잘 찾아들어오지는 않는다. 나중에 filterChain 걸 때 정의해줄 것이다.
3. AuthenticationProvider
package Likelion.SpringStudy.config.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
// 검증
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String nickname = authentication.getName();
String password = (String)authentication.getCredentials();
UserContext userContext = (UserContext)userDetailsService.loadUserByUsername(nickname);
if (!passwordEncoder.matches(password, userContext.getUser().getPassword())) {
throw new BadCredentialsException("BadCredentialsException");
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userContext.getUser(), null, userContext.getAuthorities());
return authenticationToken;
}
// 토큰 타입과 일치할 때 인증
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
이후에 Bean으로 등록된 이 클래스가 입력된 유저 정보를 잘 가져왔다면 authentication에 잘 담겨있을 것이다.
그렇다면 위에서 만들어준 loadUserByUsername으로 가져온 nickname을 던져주었을 때, 존재한다면 DB에 등록된 User 객체를 가져올 테니 비교연산을 수행해준 후에 authenticationToken을 return해주면 된다.
4. AuthenticationProvider을 Bean으로 등록
package Likelion.SpringStudy.config.security;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity(debug = true)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class WebSecurityConfig {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
}
@Bean
AuthenticationProvider authenticationProvider() {
return new CustomAuthenticationProvider();
}
}
PasswordEncoder와 AuthenticationProvice를 Bean에 등록해주기만 한다면 나머지는 SpringSecurity가 알아서 잘 Mapping하여 작업을 수행해줄 것이다.
참고로 configure는 해당 데이터 타입에 대해서는 권한 인증을 수행하지 않기 위함이다.
5. SpringFilterChain을 이용하여 Client의 정보 가공
package Likelion.SpringStudy.config.security;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity(debug = true)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class WebSecurityConfig {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
}
@Bean
AuthenticationProvider authenticationProvider() {
return new CustomAuthenticationProvider();
}
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login") // controller mapping
.loginProcessingUrl("/login_proc") // th:action="@{/login_proc}"
.usernameParameter("nickname")
.defaultSuccessUrl("/")
.failureUrl("/users/register")
.permitAll();
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.permitAll();
http.exceptionHandling().accessDeniedPage("/denied");
return http.build();
}
}
최종적으로 filterChain까지 정의해주면 성공이다.
이후에는 Controller에서 URL을 잘 매핑해주면 된다.
package Likelion.SpringStudy.controller;
import Likelion.SpringStudy.dto.UserForm;
import Likelion.SpringStudy.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
@RequiredArgsConstructor
public class SignController {
private final UserService userService;
@GetMapping("/users/register")
public String registerForm(Model model) {
model.addAttribute("userForm", new UserForm());
return "users/userRegisterForm";
}
@PostMapping("/users/register")
public String create(UserForm form) {
userService.register(form);
return "redirect:/";
}
@GetMapping("/login")
public String SignInForm() {
return "users/login";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
return "users/login";
}
}
Spring Security의 기능은 이것보다 훨씬 다양하고 강력하다.
지금까지 다룬 건 정말 기초 중의 기초밖에 되지 않을 정도기 때문에 개인적으로 더 많은 기능을 구현하면서 숙달시켜야겠다.