Spring Security
를 사용하여 로그인을 구현 가능하다. 하지만 초기설정이 어려워 Spring Security
를 사용하지 않고 직접 로그인을 구현하는 방식도 존재한다. Spring Security
의 사용유무에 따라 구현 코드도 크게 달라지는것처럼 느껴졌다.Spring Boot
의 버전(2.0 ~ or 3.0 ~)에 따라 작성해야하는 코드가 달라지기도 한다.Spring Security
를 사용한 아주 간단한 폼로그인을 구현하고자 한다.로그인 페이지 (/login) : GET방식, 로그인 View, 회원가입 페이지 링크
회원가입 페이지 (/singup) : GET방식, 회원가입 View, 로그인 페이지 링크
인증 페이지 (/home) : GET방식, 인증된 회원만 접근 가능한 페이지
로그인 API : 기본적으로 Spring Security
에서 POST방식의 /login을 지원한다.
Spring Security
에서 제공하는 인증이 아닌 다른 인증(JWT)으로 진행하고자 한다면 해당 메서드를 구현하면 된다. (url은 설정해서 변경 가능하다)
로그인 성공 시 /home으로 리다이렉트 예정이다.
회원가입 API (/api/v1/auth/signup) : 회원가입은 지원하지 않으므로 구현하였다.
로그아웃 API (/api/v1/auth/logout) : 로그아웃 기능은 Spring Security
에서 제공하므로 기본 경로를 변경만 해주었다.
lombok
, View를 구현하기 위한 thymeleaf
, 그리고 Spring Security
를 사용하였다.@Controller
@RequiredArgsConstructor
public class AuthController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/signup")
public String signup() {
return "signup";
}
@GetMapping("/home")
public String home() {
return "home";
}
}
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthApiController {
private final AuthService authService;
@PostMapping("/signup")
public String signup(SignUpRequestDto signUpRequestDto) {
authService.signup(signUpRequestDto);
return "redirect:/login";
}
}
Spring Security
가 제공하는 로그인 기능을 사용할 것이므로 Signup만 구현하였다.authService
에서 signup()
을 호출한다.@Getter
@AllArgsConstructor
public class SignUpRequestDto {
private String email;
private String password;
public Member toEntity(String password) {
return Member.builder()
.email(email)
.password(password)
.role(Role.USER)
.type(Type.FORM)
.build();
}
}
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password", nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Role role;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private Type type;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(Role.USER.toString()));
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return password;
}
}
unique = true
로 설정하였다.UserDetails
를 implement한 이유는 UserDetails
가 로그인 시 사용되는 회원 정보이기 때문이다. Member
가 UserDetails
를 구현하여 UserDetails
로 사용되도록 하였다. UserDetails
에 관한 건 뒤에서 후술할 예정이다.Role
은 enum 타입으로 아래와 같다public enum Role {
USER, ADMIN
}
Type
은 enum타입으로 아래와 같다. 폼로그인이냐, 소셜로그인이냐를 구분할 때 사용할 예정이다.public enum Type {
FORM
}
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
Member
를 db에 저장하기 위한 인터페이스이다.Member
를 찾아야하는 로직이 있으므로 findByEmail(String email)
을 생성하였다.@Service
@RequiredArgsConstructor
public class AuthService {
private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
public void signup(SignUpRequestDto signUpRequestDto) {
String encodedPassword = passwordEncoder.encode(signUpRequestDto.getPassword());
Member member = signUpRequestDto.toEntity(encodedPassword);
memberRepository.save(member);
System.out.println("save success in service");
}
}
SignUpRequestDto
를 받아오고 PasswordEncoder
라는 encoder를 불러와 패스워드를 암호화하여 Member
Entity로 변환한다.save
하여 db에 저장한다.Authentication(인증)
과, Authorization(인가)
기능을 제공하여 보안 기능을 담당하는 프레임워크이다.build.gradle
에 아래와 같이 추가함으로써 불러올 수 있다.dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
}
Spring Security
에서 중요한 개념 중 하나이다.Spring Boot
는 Client가애플리케이션으로 HTTP 요청을 전송하면 이를 DispatcherServlet
이 받아 Controller
를 실행한다. Spring Security
를 사용하게 되면 HTTP 요청이 DispatcherServlet
에 도달하기 전에 FilterChain
이라 불리는 Filter
들의 집합부터 지나게 된다.Filter
들을 거치게 되면서 인증, 인가, 로그아웃, 세션 등과 같은 기능을 수행하게 된다. 설정을 통해 어떤 필터를 거칠지, 어떤 필터를 직접 구현해서 쓸지에 따라 충분히 커스텀 가능하다. 해당 Filter
의 기능에 대해서는 추후 정리해볼 예정이다.SecurityFilterChain
이라는 FilterChain으로 Form Login 과정이 구체화된다. 과정은 다음과 같다.SecurityFilterChain
에서 인증이 필요한 리소스에 인증되지 않은 요청이 들어오면 AccessDeniedException
예외를 날린다.LoginUrlAuthenticationEntryPoint
로 하여금 /login 리다이렉트를 전송한다.사용자가 username과 password를 보내면 UsernamePasswordAuthenticationFilter
이 Authentication의 한 종류인 UsernamePasswordAuthenticationToken
를 생성한다.
UsernamePasswordAuthenticationToken
은 AuthenticationManager
로 전달되어 인증받는다.
AuthenticationManager
의 역할은 다음과 같다. UsernamePasswordAuthenticationToken
에 저장되어있는 username, password로 UserDetailsService
를 호출하여 Member
중에 존재하는 회원인지 체크한다. 이때 AuthenticationManager
가 Signup때 db Member테이블에서 찾을 수 있도록 UserDetailsService
을 구현해야한다.
회원이 존재할 때, 인증에 성공한다. 회원이 없으면 인증에 실패한다.
인증에 실패하면, SecurityContextHolder
가 지워진다. SecurityContextholder
는 현재 사용자의 Authentication
을 저장하는 SecurityContext
를 관리한다.
그 후 AuthenticationFailureHandler
가 실행된다. 이를 implement하여 사용자 정의로 실패 시 어떤 로직을 수행할지 결정할 수 있다.
인증에 성공하면, 인증에 실패했을 때의 반대처럼 행동한다. SecurityContextHolder
에 사용자의 Authentication
이 설정되고 AuthenticationSuccessHandler
가 실행된다.
Spring Security
설정을 작성할 것이다. SecurityFilterChain
를 빈으로 등록하여 폼로그인 시 필요한 설정을 맞춰야 한다.@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomAuthSuccessHandler authSuccessHandler;
private final CustomAuthFailureHandler authFailureHandler;
private static final String[] AUTH_WHITELIST = {
"/swagger-ui/**", "/", "/login", "/signup",
"/api/v1/auth/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CSRF, CORS, BasicHttp 비활성화
http
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable);
.httpBasic(AbstractHttpConfigurer::disable);
//세션 관리 구성
http
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));
//ForLogin, Logout 활성화
http
.formLogin(form -> form
.loginPage("/login")
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
)
.logout(logout -> logout
.logoutUrl("/api/v1/auth/logout")
.logoutSuccessUrl("/login")
)
// permit, authenticated 경로 설정
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll()
// 지정한 경로는 인증 없이 접근 허용
.anyRequest().authenticated());
// 나머지 모든 경로는 인증 필요
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@EnableWebSecurity
: Web Security를 활성화하겠다는 어노테이션@Configuration
: 해당 어노테이션으로 해당 클래스가 Spring 설정 클래스로 인식되도록 함authSuccessHandler
에서는 인증이 필요한 /home으로 리다이렉트되도록 설정하였고,authFailureHandler
에서는 /login?error=true로 리다이렉트되도록 설정하였다.SecurityContextHolder
에서 사용자의 인증정보를 삭제하는 로직을 수행한다.requestMatchers
에 들어가는 경로들을 permitAll()
하여 해당 경로들은 인가 없이 사용 가능하도록 했다. 이에 들어가는 경로들은 "/swagger-ui/**", "/", "/login", "/signup", "/api/v1/auth/**"
와 같은 swagger, auth관련한 인가가 필요없는 경로들을 넣어놓았다..anyRequest().authenticated()
로 그 이외의 경로들은 인증이 필요하도록 설정하였다.BCryptPasswordEncoder
을 빈으로 설정하였다. Signup 때 password를 암호화하기 위함이다.@Component
public class CustomAuthSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect("/home");
}
}
SecurityConfig
에서 사용되는 AuthenticationSuccessHandler
를 커스텀한 CustomAuthSuccessHandler
이다.@Component
public class CustomAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.sendRedirect("/login?error=true");
}
}
SecurityConfig
에서 사용되는 AuthenticationFailureHandler
를 커스텀한 CustomAuthFailureHandler
이다.@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));
}
}
AuthenticationManager
에서 사용되는 Service이다.Spring Security
에서 로그인을 수행했을 때 받아온 username과 password로 사용자가 존재하는지 존재하지 않는지 찾는 로직을 구현해놓지 않았다. 사용자를 찾기 위해 해당 서비스 작성은 필수적이다.loadUserByUsername(String email)
메서드에서 memberRepository를 통해 email로 쉽게 멤버를 찾을 수 있다.public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
....
....
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(Role.USER.toString()));
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return password;
}
}
UserDetails
를 implements 함으로써 로그인시의 username, password가 Member클래스와 연관관계를 생성하였다.getUsername()
에서 email을 반환함으로써 CustomUserDetailsService
의 loadUserByUsername(String email)
메서드에 email이 파라미터로 입력되도록 구현하였다.Spring Security
관련한 로그 레벨을 DEBUG로 설정하여 Auth과정을 확인할 수 있도록 하였다. (applitacion.properties
)logging.level.org.springframework.security= DEBUG
2024-07-11T20:20:04.218+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Securing POST /api/v1/auth/signup
2024-07-11T20:20:04.223+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-1] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2024-07-11T20:20:04.223+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-1] o.s.s.w.session.SessionManagementFilter : Request requested invalid session id 691B9E9DAF197053E87C8E75D1E29053
2024-07-11T20:20:04.224+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Secured POST /api/v1/auth/signup
Hibernate:
insert
into
member
(email, password, role, type)
values
(?, ?, ?, ?)
save success in service
2024-07-11T20:20:04.370+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Securing GET /login
2024-07-11T20:20:04.370+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-2] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2024-07-11T20:20:04.370+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-2] o.s.s.w.session.SessionManagementFilter : Request requested invalid session id 691B9E9DAF197053E87C8E75D1E29053
2024-07-11T20:20:04.371+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Secured GET /login
Set SecurityContextHolder to anonymous SecurityContext
로 보아, 로그인하지 않은 사용자를 익명 사용자로 설정했음을 알 수 있다.다음과 같이 db에 insert되었음을 볼 수 있다.
이제 로그인을 진행하자.
Authorized된 사용자만 접속 가능한 /home으로 리다이렉트 되며 아래와 같은 View가 보이게 된다.
2024-07-11T20:26:27.574+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-5] o.s.security.web.FilterChainProxy : Securing POST /login
Hibernate:
select
m1_0.member_id,
m1_0.email,
m1_0.password,
m1_0.role,
m1_0.type
from
member m1_0
where
m1_0.email=?
2024-07-11T20:26:27.788+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-5] o.s.s.a.dao.DaoAuthenticationProvider : Authenticated user
2024-07-11T20:26:27.813+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-5] w.c.HttpSessionSecurityContextRepository : Stored SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=spring.auth.entity.Member@6ef44bfb, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[USER]]] to HttpSession [org.apache.catalina.session.StandardSessionFacade@618ccf9f]
2024-07-11T20:26:27.813+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-5] w.a.UsernamePasswordAuthenticationFilter : Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=spring.auth.entity.Member@6ef44bfb, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[USER]]
2024-07-11T20:26:27.817+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-6] o.s.security.web.FilterChainProxy : Securing GET /home
Hibernate로 입력받은 email로 select문이 실행됨을 확인할 수 있다.
Authenticated user
가 출력되어 인증이 완료되었다.
HttpSessionSecurityContextRepository
가 인증된 사용자의 SecurityContext
를 HTTPSession에 저장한다. 여기서 사용자 정보는 Authentication인 UsernamePasswordAuthenticationToken
형태로 저장된다.
저장 후 /home으로 리다이렉트된다.
View로 생성하지 않았지만 로그아웃 하기 위해 /api/v1/auth/logout을 호출하면 로그아웃이 진행되어 /login으로 리다이렉트된다.
2024-07-11T20:30:38.452+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-9] o.s.security.web.FilterChainProxy : Securing GET /api/v1/auth/logout
2024-07-11T20:30:38.452+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Retrieved SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=spring.auth.entity.Member@6ef44bfb, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[USER]]]
2024-07-11T20:30:38.452+09:00 DEBUG 18384 --- [spring demo] [nio-8080-exec-9] o.s.s.w.a.logout.LogoutFilter : Logging out [UsernamePasswordAuthenticationToken [Principal=spring.auth.entity.Member@6ef44bfb, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[USER]]]
SecurityContext
에서 인증된 사용자의 정보를 가져온다.LogoutFilter
가 인증된 사용자를 로그아웃 처리한다. 로그아웃이 수행되면서 사용자의 인증 정보를 삭제한다.Spring Security
로 Form Login을 구현해보았다. 전반적인 흐름을 이해하는데 있어서 Spring 공식 문서의 도움이 컸다.Username/Password Authentication
이다. 위의 로그에서 확인했듯이 사용자의 세션 정보가 SecurityContext
에 저장되어 관리된다. 클라이언트 측에서 조작할 수 없으므로 정보의 무결성이 유지된다. 또한 서버에서 세션 만료 시간을 설정하여 자동으로 로그아웃 시키는것도 가능하다. 하지만 서버에 저장된다는 것은 서버의 리소스를 사용한다는 뜻이다.