스프링 시큐리티-커스텀 로그인 구현
시큐리티 인증 과정
스프링 시큐리티의 인증 과정은 다음과 같습니다.
-사용자의 요청을 필터가 가로챈다.
-UsernamePasswordAuthenticaionToken의 인증용 객체생성(username,password)포함
-AuthenticationManager에게 authentication객체 전달
-AuthenticationManager가 적당한 provider를 찾습니다.
-provider는 userdetails와 userdetailsService를 통해 아이디와 패스워드가 일치하는지 확인합니다.
-최종적으로 securityContextholder에 authentication객체를 저장합니다.
스프링 시큐리티에서는 securityContext에 authentication객체를 저장하였을때 인증되었다고 합니다.
기본적으로 제공하는 UserDetails 객체는 username과password,권한 정도를 저장합니다.
하지만 이 정보 이외에 이메일을 저장하고 싶다면 어떻게 할까요?
provider와 userDetails ,그리고 userDetailsService를 직접 구현하여 해결 할 수 있습니다.
먼저, UsernamePasswordAuthenticationFilter의 구조를 보자
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
"POST");
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* Enables subclasses to override the composition of the password, such as by
* including additional values and a separator.
* <p>
* This might be used for example if a postcode/zipcode was required in addition to
* the password. A delimiter such as a pipe (|) should be used to separate the
* password and extended value(s). The <code>AuthenticationDao</code> will need to
* generate the expected password in a corresponding manner.
* </p>
* @param request so that request attributes can be retrieved
* @return the password that will be presented in the <code>Authentication</code>
* request token to the <code>AuthenticationManager</code>
*/
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.passwordParameter);
}
/**
* Enables subclasses to override the composition of the username, such as by
* including additional values and a separator.
* @param request so that request attributes can be retrieved
* @return the username that will be presented in the <code>Authentication</code>
* request token to the <code>AuthenticationManager</code>
*/
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}
/**
* Provided so that subclasses may configure what is put into the authentication
* request's details property.
* @param request that an authentication request is being created for
* @param authRequest the authentication request object that should have its details
* set
*/
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
/**
* Sets the parameter name which will be used to obtain the username from the login
* request.
* @param usernameParameter the parameter name. Defaults to "username".
*/
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
/**
* Sets the parameter name which will be used to obtain the password from the login
* request..
* @param passwordParameter the parameter name. Defaults to "password".
*/
public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}
/**
* Defines whether only HTTP POST requests will be allowed by this filter. If set to
* true, and an authentication request is received which is not a POST request, an
* exception will be raised immediately and authentication will not be attempted. The
* <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
* authentication.
* <p>
* Defaults to <tt>true</tt> but may be overridden by subclasses.
*/
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return this.usernameParameter;
}
public final String getPasswordParameter() {
return this.passwordParameter;
}
}
보시면 아시겠지만 기본적으로 이 필터는 POST방식의 "/login" 요청을 가로챕니다.
또한 username 파라미터와 password파라미터를 사용하여 값을 꺼내옵니다.
따라서 로그인 폼에서 input태그의 name을 각각 username과 password로 맞추어 주어야 하는 것이죠
실습을 위한 기본 설정
application.properties
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
디렉터리 구조
SecurityConfig
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import com.example.demo.auth.CustomAuthenticationProvider;
@Configuration
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationProvider provider() {
return new CustomAuthenticationProvider();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeHttpRequests()
.requestMatchers("/user/**").authenticated()
.requestMatchers("/manager/**").hasRole("MANAGER")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
.and()
.formLogin().loginPage("/loginForm").permitAll()
.loginProcessingUrl("/login")
.defaultSuccessUrl("/");
http.authenticationProvider(provider());
return http.build();
}
}
/user url로 접근할 시에는 인증이 요구됩니다.
로그인 페이지는 /loginForm 으로 연결됩니다.
MainController
package com.example.demo.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import com.example.demo.auth.CustomUserDetails;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
@Controller
public class MainController {
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Autowired
private UserRepository repository;
@GetMapping("/")
public @ResponseBody String index() {
return "main";
}
@GetMapping("/loginForm")
public String loginForm() {
return "loginForm";
}
@GetMapping("/joinForm")
public String joinForm() {
return "joinForm";
}
@PostMapping("/join")
public String join(User user,HttpServletRequest req) {
user.setRole("ROLE_USER");
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setEmail(req.getParameter("email"));
repository.save(user);
return "redirect:/";
}
@GetMapping("/user")
public @ResponseBody String user() {
return "user";
}
}
요청처리를 확인하기 위한 간단한 컨트롤러 입니다.
/join은 회원가입을 처리하며 /user는 인증이 완료되었다면 "user"문자열을 보실수 있습니다.
로그인 페이지와 회원가입 페이지 html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입페이지</h1>
<form action="/join" method="POST">
<input type="text" name="username" placeholder="Username"/><br/>
<input type="password" name="password" placeholder="Password" />
<input type="email" name="email" placeholder="email" />
<button>회원가입</button>
</form>
</body>
</html>
<html>
<head>
<title>login page</title>
<meta charset="UTF-8">
</head>
<body>
<form action="/login" method="POST">
<input type="text" name="username" placeholder="username"/>
<input type="text" name="password" placeholder="password"/>
<input type="submit" value="로그인"/>
</form>
<a href="/joinForm">회원가입을 안하셨나요?</a>
</body>
</html>
User Entity
package com.example.demo.entity;
import java.sql.Timestamp;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String email;
private String role;
@CreationTimestamp
private Timestamp createDate;
}
user회원 정보를 저장하기위한 엔티티 입니다.
TimeStamp는 @CreationTimestamp에의해 자동으로 생성됩니다.
변수로는 id,username,password,email,role이 있습니다.
CustomUserDetails
package com.example.demo.auth;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.example.demo.entity.User;
public class CustomUserDetails implements UserDetails{
private User user;
public CustomUserDetails(User user) {
this.user=user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<GrantedAuthority> collection=new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collection;
}
public String getEmail() {
return user.getEmail();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
유저 정보를 저장하는 UserDetails 클래스입니다.
추후 이 클래스가 authentication의 principal에 들어가게 됩니다.
CustomUserDetailsService
package com.example.demo.auth;
import org.springframework.beans.factory.annotation.Autowired;
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 com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
@Service
public class CustomUserDetailsService implements UserDetailsService{
@Autowired
private UserRepository repository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user=repository.findByUsername(username);
return new CustomUserDetails(user);
}
}
loadUserByUsername 메서드는 username을 통해 데이터베이스에서 해당 정보를 불러와 UserDetails를 만들어 리턴합니다.
CustomAuthenticationProvider
package com.example.demo.auth;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class CustomAuthenticationProvider implements AuthenticationProvider{
@Autowired
private CustomUserDetailsService service;
@Autowired
private BCryptPasswordEncoder encoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username=(String)authentication.getPrincipal();
String password=(String)authentication.getCredentials();
CustomUserDetails user=(CustomUserDetails) service.loadUserByUsername(username);
String encodedPwd=encoder.encode(password);
if(encoder.matches(password, user.getPassword())) {
return new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
}
else {
System.out.println("에러 발생");
}
return null;
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
CustomUserDetailsService로 UserDetails를 불러옵니다
이 값과 loginForm에의해 날아온 데이터를 비교하여 일치한다면 UsernameAuthenticaitonToken을 생성하여 리턴합니다.
결과
이후 서버를 실행 시켜 회원가입 후 로그인 을 한다면 /user에서 "user"문자열을 확인하실수 있습니다.