
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에 저장되어 관리된다. 클라이언트 측에서 조작할 수 없으므로 정보의 무결성이 유지된다. 또한 서버에서 세션 만료 시간을 설정하여 자동으로 로그아웃 시키는것도 가능하다. 하지만 서버에 저장된다는 것은 서버의 리소스를 사용한다는 뜻이다.