서론
최근 진행 중인 프로젝트에서 Spring Security를 이용한 로그인 인증 파트를 맡게 되었다. 따라서 관련 스프링 강의를 보고 정리할 겸 포스팅을 진행해보려고 한다. IDE는 IntelliJ IDEA 2022.2.2 Ultimate 를 사용하였다.
참조한 강의 링크는 아래와 같다.
프로젝트 구성 및 의존성 추가
Spring Security 적용
현재 프로젝트는 maven으로 진행 중이므로 pom.xml에 아래와 같이 의존성을 추가하면 Spring Security가 적용된다.
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Spring Security 적용 시 변화
- 모든 요청은 인증을 받은 상태에서만 자원이 주어지도록 변경된다. (Spring Security에서 요청을 가로채 인증이 없는 사용자일 경우 인증 페이지로 자동으로 이동)
- 인증 방식은 Form 로그인 방식과 *httpBasic 로그인 방식이 주어진다.
- 아래와 같이 기본 로그인 페이지가 주어진다.
- 개발자용 기본 계정이 주어진다. Username: user / Password: 랜덤 문자열 (서버 기동 시 매번 달라짐)
*httpBasic 로그인: 세션을 구성하는 대신 매 요청에 사용자 이름과 비밀번호를 암호화하지 않고 헤더에 직접 포함하여 전송하는 방식. 이러한 인증 방식은 보안 측면에서 취약하기 때문에 https(SSL/TLS)를 사용하여 전송해야 한다.
application.properties 에서 설정 가능
spring.security.user.name=user
spring.security.user.password=1111
남은 과제
- 계정 추가, DB 연동, 권한 관리 등
- 실제로 서비스를 하기 위해서는 기본 보안 외 다양한 기능을 요구
사용자 정의 보안 기능
WebSecurityConfigurerAdapter
- 기본적인 웹 보안 기능의 초기화와 설정을 담당
- 이 클래스를 상속받아 SecurityConfig 생성, configure 메서드를 오버라이드하여 사용자 정의 보안 설정 구성
- HttpSecurity 클래스에서 제공하는 다양한 인증/인가 API를 이용
- 단, 이 클래스를 상속받아 Override 하는 방식은 현재 Deprecated. 현재는 해당 방식이 아닌 SecurityFilterChain 을 Bean으로 등록해 사용하는 방식을 권장한다.
SecurityConfig.java 기본 설정
package io.security.basicsecurity;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http // 인가 정책
.authorizeRequests() // 요청에 의한 보안검사
.anyRequest().authenticated(); // 어떤 요청에도 인증 요청
http // 인증 정책
.formLogin(); // 폼 로그인 방식 사용
}
}
Deprecated 대응
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http // 인가 정책
.authorizeRequests() // 요청에 의한 보안검사
.anyRequest().authenticated(); // 어떤 요청에도 인증 요청
http // 인증 정책
.formLogin(); // 폼 로그인 방식 사용
return http.build();
}
Form Login 인증
인증 API - FORM 인증
- /home url로 접근 요청
- 인증 정보가 없어 로그인 페이지로 Redirect
- 유저가 username과 password를 form에 작성하여 POST로 요청
- DB와 비교하여 맞는 정보일 경우 Session을 생성하며, 동시에 Authentication 타입의 클래스로 인증 토큰을 생성(User + Authorities). 이 Authentication 은 SecurityContext에 보관되어 사용
- 또한 SecurityContext는 Spring Security의 *인메모리 세션 저장소인 SecurityContextHolder에 저장
- 이 인증 토큰의 존재 여부를 판별하여 사용자의 인증을 지속적으로 유지
In-Memory DB: RAM을 사용하는 휘발성 DB로, I/O 작업으로 인한 지연 속도가 거의 없다.
SecurityContextHolder
- Principal : 인증 주체, username
- Credentials : 자격 증명 정보, password
- Authorities : 허용된 권한들, roles (복수 가능)
SecurityConfig.java
package io.security.basicsecurity;
import org.springframework.context.annotation.Configuration;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http // 인가 정책
.authorizeRequests()
.anyRequest().authenticated();
http // 인증 정책
.formLogin()
.loginPage("/loginPage") // 실제 로그인이 이루어지는 페이지 (default: /login)
.defaultSuccessUrl("/") // 로그인 성공 후 이동 페이지
.failureUrl("/login") // 로그인 실패 후 이동 페이지
.usernameParameter("userId") // 아이디 파라미터명 설정
.passwordParameter("passwd") // 패스워드 파라미터명 설정
.loginProcessingUrl("/login_proc") // 로그인 Form Action Url (default: /login)
.successHandler(new AuthenticationSuccessHandler() { // 로그인 성공 후 핸들러
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("authentication = " + authentication.getName());
response.sendRedirect("/");
}
})
.failureHandler(new AuthenticationFailureHandler() { // 로그인 실패 후 핸들러
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("exception = " + exception.getMessage());
response.sendRedirect("/login");
}
})
.permitAll();
}
}
SecurityController.java (loginPage 테스트용)
package io.security.basicsecurity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SecurityController {
@GetMapping("/")
public String index() {
return "home";
}
@GetMapping("loginPage")
public String loginPage() {
return "loginPage";
}
}
- 기본 로그아웃 페이지는 localhost:8080/logout 경로로 접근 가능
관리자 모드로 Form을 확인해보면 위와 같이 username 파라미터는 userId로, password 파라미터는 passwd로 설정된 것을 확인할 수 있고, form의 action(데이터가 도착할 url) 또한 /login_proc으로 정상적으로 변경돼있음을 확인할 수 있다.
Form Login 인증 필터 : UsernamePasswordAuthenticationFilter
Form Login의 인증 로직 Flow
- 유저의 요청이 UsernamePasswordAuthenticationFilter에 걸린다
- AntPathRequestMatcher(/login)
요청 정보가 SecurityConfig에서 설정한 loginProcessingUrl과 매칭되는지 비교 (기본값은 /login)
요청 정보가 /login이 아니면 다른 필터(chain.doFilter)로 이동, 맞으면 인증 처리 시작 - Authentication 객체에 사용자가 입력한 Username, Password를 저장하여 AuthenticationManager에 인증 처리를 요구함
- AuthenticationManager는 AuthenticationProvider에 인증 처리를 위임하는데, AuthenticationProvider에서 실제적인 인증 처리가 이루어짐
- 인증에 실패할 경우 AuthenticationException 예외를 반환하여 UsernamePasswordAuthenticationFilter 에서 예외 처리를 수행하고, 인증에 성공할 경우 Authentication 객체를 생성하여 User 정보(Username, password)와 Authorities를 담아 최종 반환한다.
- 인증 성공 후 SecurityContext에 이 인증 정보를 저장한 뒤 SuccessHandler를 수행한다.
*antMatcher의 ant는 Apache Ant Project에서 유래되었으며, 와일드카드를 사용하는 /users/** 꼴의 패턴 스타일을 의미한다.
Logout 처리, LogoutFilter
Logout Flow
- Client가 logout 요청 *(GET으로도 로그아웃 처리를 많이 하지만, 원칙적으로는 POST로 해야 함)
- Server에서 세션 무효화, 인증토큰 삭제, SecurityContext 삭제, 쿠키정보 삭제 후 로그인 페이지로 리다이렉트
* 보안에 관련된 작업은 브라우저에 저장된 캐시나 기록 등이 완전히 지워지는 것이 좋다. 그러나 GET 요청은 캐싱되기 쉽고 브라우저 기록에 저장될 수 있으므로 보안에 취약할 수 있다.
LogoutFilter
- Client의 요청이 LogoutFilter에 도달
- AntPathRequestMatcher에서 현재 요청이 /logout인지 판별 후, 맞으면 로그아웃 처리를 진행하고 아니면 다음 필터로 넘어감
- SecurityContext에서 현재 요청을 보낸 Client의 Authentication 인증 객체를 가져와 SecurityContextLogoutHandler에 전달
- SecurityContextLogoutHandler 에서 세션 무효화, 쿠키 삭제, SecurityContext 제거 등 로그아웃 처리 진행
- 로그아웃 처리가 끝나면 SimpleUrlLogoutSuccessHandler를 호출하여 로그인 페이지로 리다이렉트 (Security에서 제공하는 기본 LogoutSuccessHandler)
SecurityConfig.java
@Configuration
@EnableWebSecurity // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http // 인가 정책
.authorizeRequests()
.anyRequest().authenticated();
http // 인증 정책
.formLogin();
http // 로그아웃
.logout()
.logoutUrl("/logout") // 로그아웃 페이지
.logoutSuccessUrl("/login") // 로그아웃 후 이동할 페이지, 아래에 logoutSuccessHandler가 존재하므로 무시된다.
.addLogoutHandler(new LogoutHandler() { // 로그아웃 핸들러
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
HttpSession session = request.getSession();
session.invalidate(); // 세션 무효화
}
})
.logoutSuccessHandler(new LogoutSuccessHandler() { // 로그아웃 성공 후 핸들러
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect("/login");
}
})
.deleteCookies("remember"); // Remember Me 쿠키 삭제
}
}
- 기본 SecurityContextLogoutHandler에서도 logout 처리를 할 때 세션 무효화 및 SecurityContext 제거 등의 처리가 이루어짐
- 단, 사용자 정의 Configuration가 우선 순위
Remember Me 인증
Remeber Me 란?
- 세션 만료 및 웹 브라우저 종료 후에도 어플리케이션이 사용자를 기억하는 기능
- Remember-Me 쿠키에 대한 http 요청을 확인 후 토큰 기반 유효성 검증에 성공하면 로그인
사용자 라이프 사이클
- 인증 성공: 사용자가 인증에 성공하면 Remember-Me 쿠키를 생성하여 브라우저나 세션에 저장한다. Remember-Me 쿠키에는 인증에 사용된 사용자의 ID와 Remember-Me Token이 포함되어 있고, 다음 접속 시 Remember-Me 쿠키를 확인하여 자동으로 로그인 처리를 수행한다.
- 인증 실패: Remember-Me 쿠키가 있어도 인증에 실패하면 Remember-Me 쿠키를 삭제한다(쿠키 만료 등). 이후 사용자는 수동으로 다시 로그인을 해야한다.
- 로그아웃: 사용자가 로그아웃을 요청하면 Remember-Me 쿠키를 삭제한다. 이후 사용자는 다시 수동으로 로그인해야 한다.
SecurityConfig.java
@Configuration
@EnableWebSecurity // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http()
.rememberMe()
.rememberMeParameter("remember") // 기본값: remember-me
.tokenValiditySeconds(3600) // 기본값: 14일
.alwaysRemember(true) // 리멤버 미 기능 항상 실행 (기본값: false)
.userDetailsService(userDetailsService); // 리멤버 미 기능 이용 시 인증 계정 조회를 위해 필요
}
}
* UserDetailsService는 사용자의 아이디를 이용해 사용자의 정보를 DB 등에서 가져오는 클래스
Remember Me 인증 필터: RememberMeAuthenticationFilter
Remeber Me Flow
동작 조건
- 세션이 만료되거나 브라우저가 종료되는 등의 이유로 사용자의 SecurityContext 및 Authentication이 null일 경우 (있다면 이미 인증을 받은 것이므로 필터 동작 X)
- Remeber Me 쿠키가 존재
동작 Flow
- RemeberMeAuthenticationFilter 동작
- RemeberMeServices 인터페이스의 두 구현체 동작
- TokenBasedRemeberMeServices: 서버 메모리에 저장된 토큰과 사용자에게 있는 토큰을 비교하여 인증 (default 14일)
- PersistentTokenBasedRemeberMeServices: DB에 저장된 토큰과 사용자에게 있는 토큰을 비교하여 인증 - Token Cookie 추출
- 추출한 Token이 Remember Me 쿠키인지 비교
- 아니라면 다음 필터로 이동
- 맞다면 계속 진행 - Decode Token을 통해 토큰 Format의 유효성 검사 (여기서 Decode의 의미는 Parsing의 개념에 가까움)
- 유효하지 않을 경우 Exception - 서버의 토큰과 사용자의 토큰이 일치하는지 검사
- 일치하지 않을 경우 Exception - User 계정 조회
- 존재하지 않을 경우 Exception - 새로운 Authentication 인증 객체 생성 후 AuthenticationManager에게 전달하여 인증 처리
'Dev > Spring' 카테고리의 다른 글
[Spring Security] 인가 프로세스 DB 연동 웹 계층 구현 (0) | 2023.02.19 |
---|---|
[Spring Security] 스프링 시큐리티 기본 API 및 Filter 이해 - 2 (0) | 2022.11.23 |
[Spring / MyBatis] org.apache.ibatis.binding.BindingException: Invalid bound statement (not found) (0) | 2022.10.05 |