Spring Security
Spring Security를 활용한 Form Login 구현
공스토리맨
2023. 11. 13. 20:27
이번 포스팅에서는 Spring Security를 활용하여 가장 기본이 되는 Form Login 방식을 구현해보려고 합니다.
1. Spring Security 기본 세팅
1 - 1. Spring Security 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
1 - 2. Spring Security 코드 세팅
1 - 2 - 1. BCryptPasswordEncoder 설정
BCryptPasswordEncoder의 encode()를 이용하여 비밀번호를 암호화 할때 사용합니다.
@Configuration
public class BCryptConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
1 - 2 - 2. SecurityConfig 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests() // 인증, 인가가 필요한 URL 지정
.antMatchers("/security-login/info").authenticated() // 해당 URL은 인증 필요
.antMatchers("/security-login/admin").hasAuthority(UserRole.ADMIN.name()) // 해당 URL은 인증뿐만 아니라 ADMIN이라는 권한 필요
.anyRequest().permitAll() // 나머지 URL은 모두 허용
.and()
.formLogin() // 폼 로그인 방식 적용
.usernameParameter("loginId") // 로그인 할때 사용되는 Id
.passwordParameter("password") // 로그인 할때 사용되는 password
.loginPage("/security-login/login") // 로그인 페이지 URL
.loginProcessingUrl("/security-login/login") // form action시 시큐리티가 인터셉터하여 로그인 진행
.defaultSuccessUrl("/security-login") // 로그인 성공시 이동할 URL
.failureUrl("/security-login/login") // 로그인 실패시 이동할 URL
.and()
.logout()
.logoutUrl("/security-login/logout") // 로그아웃시 이동할 URL
.invalidateHttpSession(true).deleteCookies("JSESSIONID");
http
.exceptionHandling() // 인증, 인가 실패시 처리할 로직
.authenticationEntryPoint(new AuthenticationEntryPoint() {
// 인증 실패시 이곳에서 로직 처리
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendRedirect("/security-login/authentication-fail");
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
// 인가 실패시 이곳에서 로직 처리
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendRedirect("/security-login/authorization-fail");
}
});
// security 설정 후 H2 데이터베이스 이용시 이상한 화면 노출되지 않도록 설정
http.headers().frameOptions().disable();
}
}
1 - 2 - 3. PrincipalDetails 설정
스프링 시큐리티가 사용자의 정보를 알 수 있도록 사용자에 대한 정보를 저장하는 인터페이스입니다.
public class PrincipalDetails implements UserDetails {
private Users users;
public PrincipalDetails(Users users) {
this.users = users;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collections = new ArrayList<>();
collections.add(() -> {
return users.getRole().name();
});
return collections;
}
@Override
public String getPassword() {
return users.getPassword();
}
@Override
public String getUsername() {
return users.getLoginId();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
1 - 2 - 4. PrincipalDetailsService 설정
- PrincipalDetailsService에서는 직접 구현한 loadUserByUsername이라는 메소드를 사용하여 인증 처리를 하게 됩니다.
- 인증에 성공하게 되면, SecuritySession이 생성되고 SecuritySession은 SecuritySession(Authentication(UserDetails)) 구조로 되어있기 때문에 PrincipalDetailsService에서 PrincipalDetails를 리턴해주게 됩니다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Users user = userRepository.findByLoginId(username).orElseThrow(() -> {
return new UsernameNotFoundException("해당 유저를 찾을 수 없습니다.");
});
return new PrincipalDetails(user);
}
}
2. 코드 구현
2 - 1. Controller 구현
@Controller
@RequiredArgsConstructor
@RequestMapping("/security-login")
public class SecurityLoginController {
private final UserService userService;
@GetMapping(value = {"", "/"})
public String home(Model model, Authentication auth) {
model.addAttribute("loginType", "security-login");
model.addAttribute("pageName", "Security 로그인");
if(auth != null) {
Users loginUser = userService.getLoginUserByLoginId(auth.getName());
if (loginUser != null) {
model.addAttribute("nickname", loginUser.getNickname());
}
}
return "user/home";
}
@GetMapping("/join")
public String joinPage(Model model) {
model.addAttribute("loginType", "security-login");
model.addAttribute("pageName", "Security 로그인");
model.addAttribute("joinRequest", new JoinRequest());
return "user/join";
}
@PostMapping("/join")
public String join(@Valid @ModelAttribute JoinRequest joinRequest, BindingResult bindingResult, Model model) {
model.addAttribute("loginType", "security-login");
model.addAttribute("pageName", "Security 로그인");
// loginId 중복 체크
if(userService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
bindingResult.addError(new FieldError("joinRequest", "loginId", "로그인 아이디가 중복됩니다."));
}
// 닉네임 중복 체크
if(userService.checkNicknameDuplicate(joinRequest.getNickname())) {
bindingResult.addError(new FieldError("joinRequest", "nickname", "닉네임이 중복됩니다."));
}
// password와 passwordCheck가 같은지 체크
if(!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
bindingResult.addError(new FieldError("joinRequest", "passwordCheck", "바밀번호가 일치하지 않습니다."));
}
if(bindingResult.hasErrors()) {
return "user/join";
}
userService.join2(joinRequest);
return "redirect:/security-login";
}
@GetMapping("/login")
public String loginPage(Model model) {
model.addAttribute("loginType", "security-login");
model.addAttribute("pageName", "Security 로그인");
model.addAttribute("loginRequest", new LoginRequest());
return "user/login";
}
@GetMapping("/info")
public String userInfo(Model model, Authentication auth) {
model.addAttribute("loginType", "security-login");
model.addAttribute("pageName", "Security 로그인");
Users loginUser = userService.getLoginUserByLoginId(auth.getName());
if(loginUser == null) {
return "redirect:/security-login/login";
}
model.addAttribute("user", loginUser);
return "user/info";
}
@GetMapping("/admin")
public String adminPage( Model model) {
model.addAttribute("loginType", "security-login");
model.addAttribute("pageName", "Security 로그인");
return "user/admin";
}
@GetMapping("/authentication-fail")
public String authenticationFail() {
return "user/auth/authenticationFail";
}
@GetMapping("/authorization-fail")
public String authorizationFail() {
return "user/auth/authorizationFail";
}
}
2 - 2. Service 구현
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// Spring Security를 사용한 로그인 구현 시 사용
private final BCryptPasswordEncoder encoder;
/**
* loginId 중복 체크
* 회원가입 기능 구현 시 사용
* 중복되면 true return
*/
public boolean checkLoginIdDuplicate(String loginId) {
return userRepository.existsByLoginId(loginId);
}
/**
* nickname 중복 체크
* 회원가입 기능 구현 시 사용
* 중복되면 true return
*/
public boolean checkNicknameDuplicate(String nickname) {
return userRepository.existsByNickname(nickname);
}
/**
* 회원가입 기능 1
* 화면에서 JoinRequest(loginId, password, nickname)을 입력받아 User로 변환 후 저장
* loginId, nickname 중복 체크는 Controller에서 진행 => 에러 메세지 출력을 위해
*/
public void join(JoinRequest req) {
userRepository.save(req.toEntity());
}
/**
* 회원가입 기능 2
* 화면에서 JoinRequest(loginId, password, nickname)을 입력받아 User로 변환 후 저장
* 회원가입 1과는 달리 비밀번호를 암호화해서 저장
* loginId, nickname 중복 체크는 Controller에서 진행 => 에러 메세지 출력을 위해
*/
public void join2(JoinRequest req) {
userRepository.save(req.toEntity(encoder.encode(req.getPassword())));
}
/**
* 로그인 기능
* 화면에서 LoginRequest(loginId, password)을 입력받아 loginId와 password가 일치하면 User return
* loginId가 존재하지 않거나 password가 일치하지 않으면 null return
*/
public Users login(LoginRequest req) {
Optional<Users> optionalUser = userRepository.findByLoginId(req.getLoginId());
// loginId와 일치하는 User가 없으면 null return
if(optionalUser.isEmpty()) {
return null;
}
Users user = optionalUser.get();
// 찾아온 User의 password와 입력된 password가 다르면 null return
if(!user.getPassword().equals(req.getPassword())) {
return null;
}
return user;
}
/**
* userId(Long)를 입력받아 User을 return 해주는 기능
* 인증, 인가 시 사용
* userId가 null이거나(로그인 X) userId로 찾아온 User가 없으면 null return
* userId로 찾아온 User가 존재하면 User return
*/
public Users getLoginUserById(Long userId) {
if(userId == null) return null;
Optional<Users> optionalUser = userRepository.findById(userId);
if(optionalUser.isEmpty()) return null;
return optionalUser.get();
}
/**
* loginId(String)를 입력받아 User을 return 해주는 기능
* 인증, 인가 시 사용
* loginId가 null이거나(로그인 X) userId로 찾아온 User가 없으면 null return
* loginId로 찾아온 User가 존재하면 User return
*/
public Users getLoginUserByLoginId(String loginId) {
if(loginId == null) return null;
Optional<Users> optionalUser = userRepository.findByLoginId(loginId);
if(optionalUser.isEmpty()) return null;
return optionalUser.get();
}
}
3. 결과
- 메인 페이지 - 로그인 안한 상태
- 메인 페이지 - 로그인 한 상태
- 유저정보 클릭
- 관리자 페이지 클릭 - 관리자 권한이 없을 시, accessDeniedHandler에 설정한 URL로 이동