1. Ajax 는 FormLogin 과는 다르게 비동기 방식의 JS 통신을 말함
2. 타방법의 인증을 선택하려면 기본적으로 Form Login은 사용하지 않는 것이다.
3. Spring 은 기본적으로 Form Login 을 사용하고 있고, Form Login을 위한 설정들이 박혀 있기 때문에, 그 것을 사용하지 않으려면 Filter, Token, Provider 등을 구현해야 한다.
4. fromLogin 은 disable 후 사용하는게 좋으나, Session 저장 관련해서도 같이 끄기 때문에 SCHoler 등에 저장하는 것은 직접 따로 구현해봐야 할 듯.
=================== 인증을 위한 객체들, Ajax 와는 크게 무관. 하지만 기본적으로 필요 =============
1. Account
@Getter
@Entity
@Slf4j
@NoArgsConstructor
public class Account {
@Id
@GeneratedValue
@Column(name = "account_id")
private Long id;
private String username;
private String password;
private String email;
private String age;
private String role;
public Account(String username, String password, String email, String age, String role) {
checkWrongParams(username, password, email, age, role);
this.username = username;
this.password = password;
this.email = email;
this.age = age;
this.role = role;
}
public void checkWrongParams(String ... strs) {
boolean isWrongParam = Arrays.stream(strs).anyMatch((str)->{
// System.out.println("str = " + str);
return !StringUtils.hasText(str);
});
if (isWrongParam) {
String msg = "잘못된 변수 발생";
log.error("Error while handling Account constructor:: {}", msg);
throw new IllegalArgumentException("Error while handling Account Constructor: " + msg);
}
}
}
2. AccountDto (통신용)
@Data
@Builder
@Slf4j
@AllArgsConstructor
@NoArgsConstructor
public class AccountDto {
private String username;
private String password;
private String email;
private String age;
private String role;
}
UserDetail(User) >> 인증과 인가를 처리하기 위한 Security 객체. 연동해줘야 합니다.
3. AccountContext
public class AccountContext extends User {
private final Account account;
public AccountContext(Account account, Collection<? extends GrantedAuthority> authorities) {
super(account.getUsername(), account.getPassword(),authorities);
this.account = account;
}
public Account getAccount() {
return account;
}
}
4. Provider 에서 사용할 UserDetailsService 도 구축해주자 (Account 도 새로 만들었고, Security 연동 객체인 AccountContext 도 수월하게 사용하기 위함)
// @Service("userDetailsService") // 사실 이런 설정 좀 명확히 하고 가야함. 기존 Bean에 대하여, 자동 주입시 어떻게 넣게 되는건지?
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = userRepository.findByUsername(username);
if (Objects.isNull(account)) {
String msg = "유저의 이름을 찾을 수 없습니다";
log.error("Error while handling loadUserByName in CustomUserDetailsService:: {}", msg);
throw new UsernameNotFoundException(msg);
}
List<GrantedAuthority> roles = new ArrayList<>();
roles.add(new SimpleGrantedAuthority(account.getRole()));
// UserDetails를 만들어서 반환할 것이다
// User 클래스를 통해서 만들 수 있음
AccountContext ac = new AccountContext(account, roles);
return ac;
}
}
// @Service 를 해줬기 때문에, Config에서 따로 할필욘 없긴 함.
// 하지만 Security 자체는 기존 비즈니스 로직에서 제외된 기능 로직에 속한다.
// 따라서 저거보다는, Config 클래스에 Bean 부분, 인증 인가부분 나눠서 설정해 두면 한번에 확인할 수 있다.
// 저것도 있지만, 나는 따로 Config 클래스에 Bean 등록 해줄게요!
// 일단 여기까지의 Config는 다음과 같을 것.
* 현재까지 AjaxConfig.class 의 모습
@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService(){
return new CustomUserDetailsService();
}
// Auth & Autho 관련
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/api/**")
.authorizeRequests()
.anyRequest().authenticated(); // api 하위의 모~든 요청은 인가가 필요함.
// 지금 로그인 방식 외에는 다 쓰지 않을 거임
// 다만 formLogin Disable 을 할 경우 Session 처리 관련해서는 직접해줘야 함
http
.formLogin()
.disable();
http
.httpBasic()
.disable();
http
.csrf()
.disable();
}
}
이정도로 해두고, 본격적으로 Security에 사용할 녀석들을 추가해보자.
** 참고>> AuthenticationToken 은 Bean 등록을 하지 않는다.
우선 Authentication 객체인 AuthenticationToken 부터 만들어 봅시다. Token 은 내가 직접적으로 선언해서 사용할 것이고, 인증 전, 인증 후 객체가 나뉘게 됩니다. 또한, SecurityContextHolder 에 인증 된 녀석을 직접 넣어서 관리시킬 예정이므로, IOC 컨테이너에서 따로 관리하게 두지 않습니다. 즉, 계속 다양하게 사용해야 하므로 Bean 등록을 하지 않습니다.
다음과 같이 Token 을 만들어 봅시다. (어떻게 해야할지 모르겠으니 일단 그냥 상속 받아야 하는 AbstractAuthenticationToken.class 를 복붙하고 오류뜨는거만 해결하기.. )
public class AjaxAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
// 인증 받기 전 생성자
public AjaxAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
// 인증 받은 후 권한 부여후 생성자. 유저의 String 권한을 Security Authorization과 연동한다
public AjaxAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
public static AjaxAuthenticationToken unauthenticated(Object principal, Object credentials) {
return new AjaxAuthenticationToken(principal, credentials);
}
public static AjaxAuthenticationToken authenticated(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
return new AjaxAuthenticationToken(principal, credentials, authorities);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated,
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
그냥 복붙해서 만들었습니다... 위 객체를 토대로 Ajax 인증을 진행할 것입니다.
이제 Filter. Filter 는 우선 자신의 필터링을 거쳐야 하는 단계인지, 아니면 바로 후속으로 보내면 되는 것인지 판단을 하게 되는데, Path 가 /api/login 으로 들어오니 attemptAuthentication 을 수행하게 된다.
위에서 인증 전 AjaxToken 을 토대로 Manager에게 인증을 요청합니다. 기본적으로 Filter을 만들어보자.
public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
private ObjectMapper om = new ObjectMapper();
private final String AJAX_HEADER = "AJAX_HTTP_REQUEST";
public AjaxLoginProcessingFilter(){
super(new AntPathRequestMatcher("/api/login")); // 이 정보와 매칭되면 필터가 작동하게끔 할 수 있음 (Filter의 기능)
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
System.out.println("AjaxLoginFilter 를 지납니다");
if (!isAjax(request)) {
throw new IllegalStateException("AJAX Authentication is not supported");
}
AccountDto accountDto = om.readValue(request.getReader(), AccountDto.class);
if (!StringUtils.hasText(accountDto.getUsername()) || !StringUtils.hasText(accountDto.getPassword())) {
throw new IllegalStateException("AJAX Username or Password is Empty..");
}
// 토큰을 가지고 메니저한테 전달하는 행위임.
// 인증전이므로 이름과 Credentials 를 담아서 보낸다
AjaxAuthenticationToken ajaxAuthenticationToken = new AjaxAuthenticationToken(accountDto.getUsername(), accountDto.getPassword());
// 상속 클래스에서 알고 있는 매니저에게 처리하라고 던짐
return getAuthenticationManager().authenticate(ajaxAuthenticationToken);
}
private boolean isAjax(HttpServletRequest request) {
if (request.getHeader("X-Requested-With").equals(AJAX_HEADER)) {
return true;
}else{
return false;
}
}
}
알고 있겠지만 Filter 다음에는 Manager로 전달을 하게 됩니다. 기존 Abstract~Filter.class 를 보시면 알 수 있겟지만, 생성시 AuthenticationManager를 set해주게 되어 있고, AuthenticationManager 처리 없이 Filter를 Bean 등록해버리면 (Abstract~Filter.class 를 업고있기 때문에)
"authenticationManager must be specified"
에러가 발생하게 됩니다.
해당 필터에 걸려서 Authentication 객체를 받아서 처리하게 될 manager를 주입해줍시다. 당연히 Config 클래스에서 주입하겠죠? Manager 객체는 따로 없이 그냥 구현 객체를 사용할 것인데, WebSecurityConfigurerAdapter.class 에서 Filter의 Manager 세팅을 지원합니다. 다음과 같이 SecurityConfig.class에 추가 해줍시다.
@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
...
// 추가
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 매니저를 세팅해줘야 이 쪽으로 Auth객체를 전달을 한다
return ajaxLoginProcessingFilter;
}
// 기존 ~
@Bean
public UserDetailsService userDetailsService(){
return new CustomUserDetailsService();
}
...
}
그럼 이제 api / login 으로 username, password 정보가 들어오면, 그 정보를 토대로 AccountDto 를 형성하고, 그 정보를 토대로 Authentication 준비를 시작하게 됩니다.
이제 메니저는 자신이 가지고 있는 Provider 들을 참고하여 알맞은 Provider에게 실질적인 인증 처리를 부탁하게 됩니다. AccountDto를 실질적으로 가지고 처리를 해야하므로 AjaxCustomProvider를 또한번 만들어 봅시다.
@RequiredArgsConstructor
public class AjaxAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
System.out.println("Ajax Auth Provider 가 실행됩니다");
// UDS 가 필요
String username = authentication.getName();
String password = (String) authentication.getCredentials();
// UDS로 loadUserByName 실행
AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, accountContext.getPassword())) {
throw new BadCredentialsException("비밀번호 불일치");
}
// 일치하면 Authentication 객체가 인증된 객체로 새로 형성후 전달해 주면 됨.
// MEMO : 왜 Account, null 을 보내주는지? username, pw, Authorities 전달해줘야 하는것 아닌지?
AjaxAuthenticationToken authenticationToken = new AjaxAuthenticationToken(accountContext.getAccount(), null, accountContext.getAuthorities());
System.out.println("authenticationToken = " + authenticationToken);
return authenticationToken;
}
// 작동 조건 확인. 사용되는 Authentication 객체가 AjaxAuthToken 이것이면 이 Provider를 사용할 것이다.
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(AjaxAuthenticationToken.class);
}
}
알다싶이 provider 는 UDs 를 이용하여 동일 Id의 AccountContext 객체를 불러오고, DB에 저장된 비밀번호와 Provider 까지 전달된 비밀번호를 비교하여 일치 여부를 판단합니다. (참고로, 저 supports 함수가 사용하고 있는 토큰을 통해서 적절한 AuthenticationProvider 를 찾는 함수입니다, Manater에서 가지고 있는 Provider를 돌려서 supports 수행 결과가 참인 녀석을 가지고 하게 됩니다) 즉, 실질적인 인증이 이루어지고, 결과적으로 Authentication 객체를 새롭게 생성하여 권한까지 추가후 전달합니다.
위에서 UDS는 빈 주입을 해놨고, PasswordEncoder의 주입이 필요하므로 Config Class에 추가해주겠습니다. 또한, 이 Configuration 에서는 해당 Provider를 사용한다고 명시를 해줘야 하므로, Config Class는 다음과 같이 변경됩니다.
@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
...
// 기존 ..
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public UserDetailsService userDetailsService(){
return new CustomUserDetailsService();
}
@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 매니저를 세팅해줘야 이 쪽으로 Auth객체를 전달을 한다
return ajaxLoginProcessingFilter;
}
// 추가
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationProvider ajaxAuthenticationProvider() {
return new AjaxAuthenticationProvider(userDetailsService(), passwordEncoder());
}
...
}
Provider 에서 사용할 UserDetailsService 와 PasswordEncoder 각각의 역할체에 대해서 다음과 같이 주입을 완료하였습니다. 이제 Manager, Provider 까지 되었으니, 기본적인 통신 Line 은 구축이 되었습니다. 이제 다시 필터로 올라와서 다음 액션을 취하게 되는데, 기존 Spring에서 지원해주는 formLogin 방식 같은 경우에는 http 세팅에서 다음과 같이 간단히 추가해줄 수 있었습니다.
http
.formLogin()
.successHandler(~CUSTOM_HANDLER())
.failureHandler(~CUSTOM_HANDLER())
.permitAll()
;
하지만 Form Login 은 Disable 되어 있기 때문에, Filter 에게 Filter 처리 이후 Success, Failure에 따라 어떤 처리를 하면 되는지 명시를 해주면 됩니다.
(Filter 에서 doFilter 같은 경우 ~~가 아닌경우를 Exception으로 처리하고 그냥 뒤 필터로 진행을 시키기도 하지만, 이 Authentication 필터 같은 경우는 요청을 받아와서 처리를 끝내야 하는 곳입니다. 그러므로 Success, Failure 각각 어떻게 대응할지 Handler 등록을 해줘야 합니다)
Success Handler를 만들어봅시다.
public class AjaxAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 인증에 성공한 객체가 전달됩니다 Authentication
// 우리가 Pricipal 에 username 말고 Account 객체를 박아두면 이대로 가지고 나올 수 있음
Account successAccount = (Account) authentication.getPrincipal();
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), successAccount);
}
}
위에서 Principal과 Credential 을 따로따로 걸지 않고, Principal에 Account 전체 객체를 걸어놨기 때문에, 한번에 가지고 나올 수 있습니다. 다만, 여기서 write를 하지만, DTO로 write하는게 맞긴 하겠죠? 귀찮으니 걍 저렇게 ㄱㄱ
objectMapper를 통해서 객체로 변환, 객체로 등록을 JSON에 맞춰서 자유롭게 할 수 있습니다. 다만, 반드시 getter setter가 되어 있어야 하므로 DTO를 꼭 따로 파야 합니다. Entity 자체에는 Setter 금지이기 때문입니다.
FailureHandler
public class AjaxAuthenticationFailureHandler implements AuthenticationFailureHandler {
private ObjectMapper om = new ObjectMapper();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String errMsg = "Inavlid Username or Password";
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
if (exception instanceof BadCredentialsException) {
errMsg = "Invalid Username or Password";
} else if (exception instanceof DisabledException) {
errMsg = "Locked";
} else if (exception instanceof CredentialsExpiredException) {
errMsg = "Expired Password";
}
om.writeValue(response.getWriter(), errMsg);
}
}
이와 같이 핸들러를 만들고, 다음과 같이 Bean 주입후, Filter에 추가를 해줬습니다. 최종적인 Config Class의 모습은 다음과 같다고 볼 수 있습니다. (Authentication 처리된 한정, Session 은 아직 미처리)
@EnableWebSecurity
public class AjaxSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//// BEAN 주입
@Bean
public UserDetailsService userDetailsService() {
return new CustomUserDetailsService();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public AuthenticationProvider ajaxAuthenticationProvider() {
return new AjaxAuthenticationProvider(userDetailsService(), passwordEncoder());
}
@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler() {
return new AjaxAuthenticationSuccessHandler();
}
@Bean
public AuthenticationFailureHandler authenticationFailureHandler() {
return new AjaxAuthenticationFailureHandler();
}
@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 매니저를 세팅해줘야 이 쪽으로 Auth객체를 전달을 한다
ajaxLoginProcessingFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
ajaxLoginProcessingFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
return ajaxLoginProcessingFilter;
}
// ... HttpSecurity 설정은 맨 위와 변함X
}
다음에는 인가처리에 대해서 배워보겠슴둥
일단 인가는 Security가 계속 처리해주게 하기 위해서는 Session 을 계속 사용하면 됩니다.
Custom Filter 를 사용했을 경우에 Authentication을 반환, Security Context 에 강제 주입 등을 해주면 됩니다.
토큰 같은거는 세션에 저장안하려고 사용하는 것이지만, Security 가 Session 을 해주는 것이 편리하기 때문에 저장해서 지속 사용하기도 합니다.
현재 AJAXFIlter 같은 경우는 attemptAuthentication 을 통해서 Manager 로 넘겨지고 수행이 종료된다. AJAX는 이후 인가는 당연히 Security 에 위임할 것이기 때문에 별도의 SHolder를 제어하지는 않을 것.
추가적으로 다음과 같은 사항에서 예외처리를 직접 하고 싶을시 다음과 같이 설정해줄 수도 있다.
// 익명 사용자가 인증이 필요한 자원에 접근함
// 예외 필터가 commence를 호출해서 작업을 처리하고 Client에게 전달
public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 인증 예외가 Param 으로 전달되고 있음
// 사용자가 인증을 받지 않은 상태로 자원에 접근함
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized User!!");
}
}
// 익명 사용자가 인증이 필요한 자원에 접근함
// 예외 필터가 commence를 호출해서 작업을 처리하고 Client에게 전달
public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 인증 예외가 Param 으로 전달되고 있음
// 사용자가 인증을 받지 않은 상태로 자원에 접근함
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized User!!");
}
}
그리고 설정에는 다음과 같이 추가해주면 됩니다.
@Bean
public AjaxAccessDeniedHandler ajaxAccessDeniedHandler() {
return new AjaxAccessDeniedHandler();
}
// Auth & Autho 관련
@Override
protected void configure(HttpSecurity http) throws Exception {
...
http
.exceptionHandling()
.authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint()) // 1번 사항
.accessDeniedHandler(ajaxAccessDeniedHandler()); //2번 사항
...
}
'Spring > Spring Security' 카테고리의 다른 글
[Spring Security] 아니 왜 Forbidden 만 계속 오지?? (0) | 2023.08.17 |
---|---|
[Spring Security] 1 - 무작정 시작하는 Security와 개요 (0) | 2022.11.25 |