@Service
@Transactional
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email);
if (user == null) {
throw new UsernameNotFoundException("No user found with username: " + email);
}
boolean enabled = true;
boolean accountNonExpired = true;
boolean credentialsNonExpired = true;
boolean accountNonLocked = true;
return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, getAuthorities(user.getRoles()));
}
private static List<GrantedAuthority> getAuthorities(List<String> roles) {
List<GrantedAuthority> authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority(role));
}
return authorities;
}
}
Add a reference to the UserDetailsService
inside the authentication-manager
element & add the UserDetailsService
bean to enable the new user service in the Spring Security configuration.
@Autowired
private MyUserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsSErvice(userDetailsService);
}
Reference: User Registration
The VerificationToken
entity must meet the following criteria:
@Entity
public class VerificationToken {
private static final int EXPIRATION = 60 * 24;
@Id
@GeneratedValue(straetgy = GenerationType.AUTO)
private Long id;
private String token;
@OneToOne(targetEntity = User.class ,fetch = FetchType.EAGER)
@JoinColumn(nullable = false, name = "user_id")
private User user;
private Date expiryDate;
private Date calculateExpiryDate(int expiryTimeInMinutes) {
Calendar cal = Calendar.getInstance();
cal.setTime(new Timestamp(cal.getTime().getTime()));
cal.add(Calendar.MINUTE, expiryTimeInMinutes);
return new Date(cal.getTime().getTime());
}
// constructors, getters, etc.
}
nullable=false
ensures data integrity & consistency across VerificationToken
and User
association
public class User {
...
@Column(name = "enabled")
private boolean enabled;
public User() {
super();
this.enabled = false;
}
...
}
enabled
field
VerificationToken
for User
VerificationToken
's valueIn this context, Spring Event
will create the token & send verification email
Controller publishes a Spring ApplicationEvent
to trigger the execution of these tasks
ApplicationEventPublisher
@Autowired
ApplicationEventPublisher eventPublisher;
@PostMapping("/user/registration")
public ModelAndView registerUserAccount(@ModelAttribute("user") @Valid UserDto userDto, HttpServletRequest request, Errors errors) {
try {
User registered = userService.registerNewUserAccount(userDto);
String appUrl = request.getContextPath();
eventPublisher.publishEvent(new OnRegistrationCompleteEvent(registered, request.getLocale(), appUrl));
} catch (UserAlreadyExistException uaeEx) {
ModelAndView mav =new ModelAndView("registration", "user", userDto);
mav.addObject("message", "An account for that username/email already exists.");
return mav;
} catch (RuntimeException ex) {
return new ModelAndView("emailError", "user", userDto);
}
return new ModelAndView("successRegister", "user", userDto);
}
ModelAndView
:
- class in
org.springframework.web.servlet
package- value object designed to hold model and view
- make it possible for a handler to return both model and view in a single return value
Application event: a uniquely named situation that can be triggered by an action performed by somebody working in the system or by a condition taht occurs while the system is running
OnRegistrationCompleteEvent
public class OnRegistrationCompleteEvent extends ApplicationEvent {
private String appUrl;
private Locale locale;
private User user;
public OnRegistrationCompleteEvent(User user, Locale locale, String appUrl) {
super(user);
this.user = user;
this.locale = locale;
this.appUrl = appUrl;
}
}
RegistrationListener
will handle OnRegistrationCompleteEvent
@Component
public class RegistrationListener implements ApplicationListener<OnRegistrationCompleteEvent> {
@Autowired
private IUserService service;
@Autowired
private MessageSource messages;
@Autowired
private JavaMailSender mailSender;
@Override
public void onApplicationEvent(OnRegistrationCompleteEvent event) {
this.confirmRegistration(event);
}
private void confirmRegistration(OnRegistrationCompleteEvent event) {
User user = event.getUser();
String token = UUID.randomUUID().toString();
service.createVerificationToken(user, token);
String recipientAddress = user.getEmail();
String subject = "Registration Confirmation";
String confirmationUrl = event.getAppUrl() + "/registrationConfirm?token=" + token;
String message = messages.getMessage("message.regSucc", null, event.getLocale());
SimpleMailMessage email = new SimpleMailMessage();
email.setTo(recipientAddress);
email.setSubject(subject);
email.setText(message + "\r\n" + "http://localhost:8080" + confirmationUrl);
mailSender.send(email);
}
}
confirmRegistration
method:OnRegistrationCompleteEvent
JavaMailSender
may throw javax.mail.AuthenticationFailedException
When the user receives the confirm registration link, they should click on it.
When the user clicks on the link, the controller:
RegistrationController
processes the registration confirmation
@Autowired
private IUserService service;
@GetMapping("/registrationConfirm")
public String confirmRegistration(WebRequest request, Model model, @RequestParam("token") String token) {
Locale locale = request.getLocale();
VerificationToken verificationToken = service.getVerificationToken(token);
if (verificationToken == null) {
String message = messages.getMessage("auth.message.invalidToken", null, locale);
model.addAttribute("message", message);
return "redirect:/badUser.html?lang=" + locale.getLanguage();
}
User user = verificationToken.getUser();
Calendar cal = Calendar.getInstance();
if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
String messageValue = messages.getMessage("auth.message.expired", null, locale);
model.addAttribute("message", messageValue);
return "redirect:/badUser.html?lang=" + locale.getLanguage();
}
user.setEnabled(true);
service.saveRegisteredUser(user);
return "redirect:/login.html?lang=" + request.getLocale().getLanguage();
}
Locale object: represents a specific geographical, political, or cultural region
It is necessary to check if the user is enabled. We implement this through the loadUserByUsername
method in MyUserDetailsService
@Autowired
UserRepository userRepository;
public UserDetails loadUserByUsername(String email throws UsernameNotFoundException {
boolean enabled = true;
boolean accountNonExpired = true;
boolean credentialsNonExpired = true;
boolean accountNonLocked = true;
try {
User user = userRepository.findByEmail(email);
if (user == null) {
throw new UsernameNotFoundException("No user found with username: " + email);
}
return new org.springrframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword().toLowerCase(),
user.isEnabled(),
accountNonExpired,
credentialsNonExpired,
accountNonLocked,
getAuthorities(user.getRole()));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
AuthenticationFailureHandler
: customizes the exception messages coming from the above class
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
private MesasgeSource messages;
@Autowired
private LocaleResolver localeResolver;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
setDefaultFailureUrl("/login.html?error=true");
super.onAuthenticationFailure(request, response, exception);
Locale locale = localeResolver.resolveLocale(request);
String errorMessage = messages.getMessage("message.badCredentials", null, locale);
if (exception.getMessage().equalsIgnoreCase("User is disabled")) {
errorMessage = messages.getMessage("auth.message.disabled", null, locale);
} else if (exception.getMessage().equalsIgnoreCase("User account has expired")) {
errorMessage = messages.getMessage("auth.message.expired", null, locale);
}
request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, errorMessage);
}
}
IUserService
public interface IUserService {
User registerNewUserAccount(UserDto userDto) throws UserAlreadyExistException;
User getUser(String verificationToken);
void saveRegisteredUser(User user);
void createVerificationToken(User user, String token);
VerificationToken getVerificationToken(String VerificationToken);
}
UserService
@Service
@Transactional
public class UserService implements IUserService {
@Autowired
private UserRepository userRepository;
@Autowired
private VerificationTokenRepository tokenRepository;
@Override
public User registerNewUserAccount(UserDto userDto) throws UserAlreadyExistException {
if (emailExist(userDto.getEmail())) {
throw new UserAlreadyExistException("There is an account with that email address: " + userDto.getEmail());
}
User user = new User();
user.setFirstName(userDto.getFirstName());
user.setLastName(userDto.getLastName());
user.setPassword(userDto.getPassword());
user.setEmail(userDto.getEmail());
user.setRole(new Role(Integer.valueOf(1), user));
return repository.save(user);
}
private boolean emailExist(String email) {
return userRepository.findByEmail(email) != null;
}
@Override
public User getUser(String verificationToken) {
User user = tokenRepository.findByToken(verificationToken).getUser();
return user;
}
@Override
public VerificationToken getVerificationToken(String verificationToken) {
return tokenRepository.findByToken(VerificationToken);
}
@Override
public void saveRegisteredUser(User user) {
userRepository.save(user);
}
@Override
public void createVerificationToken(User user, String token) {
VerificationToken myToken = new VerificationToken(token, user);
tokenRepository.save(myToken);
}
}
References
@GetMapping("/user/resendRegistrationToken")
public GenericResponse resendRegistrationToken(HttpServletRequest request, @RequestParam("token") String existingToken) {
VerificationToken newToken = userService.generateNewVerificationToken(existingToken);
User user = userService.getUser(newToken.getToken());
String appUrl = "http://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath();
SimpleMailMessage email = constructResendVerificationTokenEmail(appUrl, request.getLocale(), newToken, user);
mailSender.send(email);
return new GenericResponse(messages.getMessage("message.resendToken", null, request.getLocale()));
}
Custom exception handler
@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@Autowired
private MessageSource messages;
@ExceptionHandler({ UserNotFoundException.class })
public ResponseEntity<Object> handleUserNotFound(RuntimeException ex, WebRequest request) {
logger.error("404 Status Code", ex);
GenericResponse bodyOfResponse = new GenericResponse(messages.getMessage("message.userNotFound", null, request.getLocale()), "UserNotFound");
return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.NOT_FOUND, request);
}
...
@ControllerAdvice
: annotation to handle exceptions across whole applicationGenericResponse
: simple response object custom made in this exampleReferences
Servlet Filters
가 스프링 시큐리티의 Servlet
지원의 기반이다. 그럼 Filters
의 역할은 무엇일까?
아래 이미지로 HTTP 요청을 처리하는 핸들러들의 계층을 확인해보자
[이미지 출처]
FilterChain
을 생성한다:Filter
인스턴스들HttpServletRequest
를 처리할 Servlet
스프링부트 애플리케이션에서
Servlet
은DispatcherServlet
의 인스턴스로, 최대 한개의Servlet
이 하나의HttpServletRequest
와HttpServletResponse
를 처리할 수 있다.
Filter
인스턴스 또는 Servlet
이 불리지 않도록 방지한다. 이 경우에는 HttpServletResponse
를 작성한다Filter
인스턴스와 Servlet
이 사용하는 HttpServletRequest
또는 HttpServletResponse
을 수정한다Filter
의 권한은 넘겨받는 FilterChain
이 결정한다
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// do something before the rest of the application
chain.doFilter(request, response); // invoke the rest of the application
// do something after the rest of the application
}
DelegatingFilterProxy
Filter
의 구현ApplicationContext
를 잇는 역할Filter
인스턴스의 등록은 받을 수 있지만, 스프링이 정의한 빈에 대해서는 반응하지 못한다DelegatingFilterProxy
를 표준 서블렛 컨테이너 방식으로 등록하고, Filter
를 구현하는 스프링빈에 일을 넘긴다
[이미지 출처]
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName);
delegate.doFilter(request, response);
}
Filter
를 가져온다DelegatingFilterProxy
는 ApplicationContext
에서 Bean Filter_0을 찾는다Filter
빈 인스턴스 찾기를 Lazy하기 수행하기 때문에 Filter
인스턴스가 등록되어야할 때 ContextLoaderListener
로 스프링빈을 로드한다
FilterChainProxy
Filter
SecurityFilterChain
을 통해 여러 Filter
인스턴스에 일을 넘긴다 DelegatingFilterProxy
에 랩핑(wrapping)되어 있음
[이미지 출처]
SecurityFilterChain
FilterChainProxy
가 SecurityFilterChain
으로 이 요청에는 어느 스프링 시큐리티 Filter
인스턴스를 불러야 할지 판단한다
[이미지 출처]
SecurityFilterChain
의 security filter는 주로 FilterChainProxy
로 등록된 빈이다Security Context
를 비운다 위 사진과 같이 SecurityFilterChain
이 여러 개 함께 있는 경우에는 FilterCHainProxy
가 어느 SecurityFilterChain
을 사용할지 결정한다
(첫 매칭을 주로 사용한다)
Security Filter
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
}
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String tenantId = request.getHeader("X-Tenant-Id");
boolean hasAccess = isUserAllowed(tenantId);
if (hasAccess) {
filterChain.doFilter(request, response);
return;
}
throw new AccessDeniedException("Access denied");
}
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
return http.build();
}
새 필터 (TenantFilter
)가 AuthorizationFilter
앞 순서로 추가됐음으로 AuthenticationFilter
다음 순서로 수행될 것임
AccessDeniedException
AuthenticationException
ExceptionTranslationFilter
: 위 exception을 HTTP response으로 번역한다 ExceptionTranslationFilter
가 FilterChain.doFilter(request, response)
를 호출하면, doFilter
가 나머지 애플리케이션을 호출한다AuthenticationException
)면 인증을 시작한다 HttpServlet
을 저장한다 (인증 성공시 기존 요청 수행)AuthenticationEntryPoint
으로 클라이언트로부터 인증정보를 받아온다 (로그인 페이지 등)AccessDeniedHandler
가 호출된다 try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
인증을 필요로 하는 리소스에 인증 없이 요청이 들어오면, 이 요청을 저장해두고 인증 성공시 요청을 수행해야 한다
HttpServletRequest
는 RequestCache
에 저장된다RequestCacheAwareFilter
가 RequestCache
로 저장된 요청을 재수행한다 continue
paramter가 있어야지만 저장된 요청을 확인한다 @Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
requestCache.setMatchingRequestParameterName("continue");
http
// ...
.requestCache((cache) -> cache
.requestCache(requestCache)
);
return http.build();
}
@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
RequestCache nullRequestCache = new NullRequestCache();
http
// ...
.requestCache((cache) -> cache
.requestCache(nullRequestCache)
);
return http.build();
}
References: