diff --git a/build.gradle b/build.gradle index bfa01422..3a6e1c1f 100644 --- a/build.gradle +++ b/build.gradle @@ -20,11 +20,14 @@ configurations { repositories { mavenCentral() } - +//FIXME dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa:' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-thymeleaf', version: '3.2.2' + implementation group: 'org.thymeleaf.extras', name: 'thymeleaf-extras-springsecurity6', version: '3.1.2.RELEASE' + implementation 'org.modelmapper:modelmapper:3.1.0' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' diff --git a/src/main/java/io/security/springsecuritymaster/controller/DashboardController.java b/src/main/java/io/security/springsecuritymaster/controller/HomeController.java similarity index 80% rename from src/main/java/io/security/springsecuritymaster/controller/DashboardController.java rename to src/main/java/io/security/springsecuritymaster/controller/HomeController.java index 7ca3d020..1f4391b8 100644 --- a/src/main/java/io/security/springsecuritymaster/controller/DashboardController.java +++ b/src/main/java/io/security/springsecuritymaster/controller/HomeController.java @@ -5,7 +5,7 @@ import org.springframework.web.bind.annotation.GetMapping; @Controller -public class DashboardController { +public class HomeController { @GetMapping(value="/") public String dashboard() { return "/dashboard"; @@ -25,4 +25,9 @@ public String manager() { public String admin() { return "/admin"; } + + @GetMapping(value="/api") + public String restDashboard() { + return "rest/dashboard"; + } } \ No newline at end of file diff --git a/src/main/java/io/security/springsecuritymaster/domain/dto/AccountContext.java b/src/main/java/io/security/springsecuritymaster/domain/dto/AccountContext.java new file mode 100644 index 00000000..7911a233 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/domain/dto/AccountContext.java @@ -0,0 +1,47 @@ +package io.security.springsecuritymaster.domain.dto; + +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Data +public class AccountContext implements UserDetails { + private AccountDto accountDto; + private final List roles; + + public AccountContext(AccountDto accountDto, List roles) { + this.accountDto = accountDto; + this.roles = roles; + } + @Override + public Collection getAuthorities() { + return roles; + } + @Override + public String getPassword() { + return accountDto.getPassword(); + } + @Override + public String getUsername() { + return accountDto.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; + } +} diff --git a/src/main/java/io/security/springsecuritymaster/domain/dto/AccountDto.java b/src/main/java/io/security/springsecuritymaster/domain/dto/AccountDto.java new file mode 100644 index 00000000..4cbfe77f --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/domain/dto/AccountDto.java @@ -0,0 +1,20 @@ +package io.security.springsecuritymaster.domain.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AccountDto { + private String id; + private String username; + private String password; + private int age; + private String roles; +} diff --git a/src/main/java/io/security/springsecuritymaster/domain/entity/Account.java b/src/main/java/io/security/springsecuritymaster/domain/entity/Account.java new file mode 100644 index 00000000..56e6e825 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/domain/entity/Account.java @@ -0,0 +1,18 @@ +package io.security.springsecuritymaster.domain.entity; + +import jakarta.persistence.*; +import lombok.Data; +import java.io.Serializable; + +@Entity +@Data +public class Account implements Serializable { + + @Id + @GeneratedValue + private Long id; + private String username; + private String password; + private String roles; + private int age; +} diff --git a/src/main/java/io/security/springsecuritymaster/security/configs/AuthConfig.java b/src/main/java/io/security/springsecuritymaster/security/configs/AuthConfig.java new file mode 100644 index 00000000..fb5a8953 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/configs/AuthConfig.java @@ -0,0 +1,15 @@ +package io.security.springsecuritymaster.security.configs; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class AuthConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + +} diff --git a/src/main/java/io/security/springsecuritymaster/security/configs/SecurityConfig.java b/src/main/java/io/security/springsecuritymaster/security/configs/SecurityConfig.java index 64fc0883..1d40c160 100644 --- a/src/main/java/io/security/springsecuritymaster/security/configs/SecurityConfig.java +++ b/src/main/java/io/security/springsecuritymaster/security/configs/SecurityConfig.java @@ -1,34 +1,98 @@ package io.security.springsecuritymaster.security.configs; +import io.security.springsecuritymaster.security.entrypoint.RestAuthenticationEntryPoint; +import io.security.springsecuritymaster.security.filters.RestAuthenticationFilter; +import io.security.springsecuritymaster.security.handler.*; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.WebAuthenticationDetails; @EnableWebSecurity @Configuration +@RequiredArgsConstructor public class SecurityConfig { + private final AuthenticationProvider authenticationProvider; + private final AuthenticationProvider restAuthenticationProvider; + private final AuthenticationDetailsSource authenticationDetailsSource; + private final FormAuthenticationSuccessHandler successHandler; + private final FormAuthenticationFailureHandler failureHandler; + private final RestAuthenticationSuccessHandler restSuccessHandler; + private final RestAuthenticationFailureHandler restFailureHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http .authorizeHttpRequests(auth -> auth - .requestMatchers("/").permitAll() + .requestMatchers("/css/**", "/images/**", "/js/**", "/favicon.*", "/*/icon-*").permitAll() + .requestMatchers("/","/signup","/login*").permitAll() + .requestMatchers("/user").hasAuthority("ROLE_USER") + .requestMatchers("/manager").hasAuthority("ROLE_MANAGER") + .requestMatchers("/admin").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated()) - .formLogin(Customizer.withDefaults()) + + .formLogin(form -> form + .loginPage("/login") + .authenticationDetailsSource(authenticationDetailsSource) + .successHandler(successHandler) + .failureHandler(failureHandler) + .permitAll()) + .authenticationProvider(authenticationProvider) + .exceptionHandling(exception -> exception + .accessDeniedHandler(new FormAccessDeniedHandler("/denied")) + ) ; return http.build(); } + @Bean - public UserDetailsService userDetailsService(){ - UserDetails user = User.withUsername("user").password("{noop}1111").roles("USER").build(); - return new InMemoryUserDetailsManager(user); + @Order(1) + public SecurityFilterChain restSecurityFilterChain(HttpSecurity http) throws Exception { + + AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder.authenticationProvider(restAuthenticationProvider); + AuthenticationManager authenticationManager = authenticationManagerBuilder.build(); // build() 는 최초 한번 만 호출해야 한다 + + http + .securityMatcher("/api/**") + .authorizeHttpRequests(auth -> auth + .requestMatchers("/css/**", "/images/**", "/js/**", "/favicon.*", "/*/icon-*").permitAll() + .requestMatchers("/api","/api/login").permitAll() + .requestMatchers("/api/user").hasAuthority("ROLE_USER") + .requestMatchers("/api/manager").hasAuthority("ROLE_MANAGER") + .requestMatchers("/api/admin").hasAuthority("ROLE_ADMIN") + .anyRequest().authenticated()) + .csrf(AbstractHttpConfigurer::disable) + .addFilterBefore(restAuthenticationFilter(http, authenticationManager), UsernamePasswordAuthenticationFilter.class) + .authenticationManager(authenticationManager) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new RestAuthenticationEntryPoint()) + .accessDeniedHandler(new RestAccessDeniedHandler()) + ) + ; + return http.build(); + } + + private RestAuthenticationFilter restAuthenticationFilter(HttpSecurity http, AuthenticationManager authenticationManager) { + + RestAuthenticationFilter restAuthenticationFilter = new RestAuthenticationFilter(http); + restAuthenticationFilter.setAuthenticationManager(authenticationManager); + restAuthenticationFilter.setAuthenticationSuccessHandler(restSuccessHandler); + restAuthenticationFilter.setAuthenticationFailureHandler(restFailureHandler); + + return restAuthenticationFilter; } -} +} \ No newline at end of file diff --git a/src/main/java/io/security/springsecuritymaster/security/details/FormWebAuthenticationDetails.java b/src/main/java/io/security/springsecuritymaster/security/details/FormWebAuthenticationDetails.java new file mode 100644 index 00000000..d68e12e0 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/details/FormWebAuthenticationDetails.java @@ -0,0 +1,16 @@ +package io.security.springsecuritymaster.security.details; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +@Getter +public class FormWebAuthenticationDetails extends WebAuthenticationDetails { + + private final String secretKey; + + public FormWebAuthenticationDetails(HttpServletRequest request) { + super(request); + secretKey = request.getParameter("secret_key"); + } +} \ No newline at end of file diff --git a/src/main/java/io/security/springsecuritymaster/security/details/FormWebAuthenticationDetailsSource.java b/src/main/java/io/security/springsecuritymaster/security/details/FormWebAuthenticationDetailsSource.java new file mode 100644 index 00000000..5377120f --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/details/FormWebAuthenticationDetailsSource.java @@ -0,0 +1,14 @@ +package io.security.springsecuritymaster.security.details; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.stereotype.Component; + +@Component +public class FormWebAuthenticationDetailsSource implements AuthenticationDetailsSource { + @Override + public WebAuthenticationDetails buildDetails(HttpServletRequest request) { + return new FormWebAuthenticationDetails(request); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/entrypoint/RestAuthenticationEntryPoint.java b/src/main/java/io/security/springsecuritymaster/security/entrypoint/RestAuthenticationEntryPoint.java new file mode 100644 index 00000000..9b9f0a56 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/entrypoint/RestAuthenticationEntryPoint.java @@ -0,0 +1,24 @@ +package io.security.springsecuritymaster.security.entrypoint; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.getWriter().write(mapper.writeValueAsString(HttpServletResponse.SC_UNAUTHORIZED)); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/exception/SecretException.java b/src/main/java/io/security/springsecuritymaster/security/exception/SecretException.java new file mode 100644 index 00000000..885ff54b --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/exception/SecretException.java @@ -0,0 +1,9 @@ +package io.security.springsecuritymaster.security.exception; + +import org.springframework.security.core.AuthenticationException; + +public class SecretException extends AuthenticationException { + public SecretException(String msg) { + super(msg); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/filters/RestAuthenticationFilter.java b/src/main/java/io/security/springsecuritymaster/security/filters/RestAuthenticationFilter.java new file mode 100644 index 00000000..36a7412f --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/filters/RestAuthenticationFilter.java @@ -0,0 +1,59 @@ +package io.security.springsecuritymaster.security.filters; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.security.springsecuritymaster.domain.dto.AccountDto; +import io.security.springsecuritymaster.security.token.RestAuthenticationToken; +import io.security.springsecuritymaster.util.WebUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.context.DelegatingSecurityContextRepository; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.StringUtils; + +import java.io.IOException; + +public class RestAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + private final ObjectMapper objectMapper = new ObjectMapper(); + public RestAuthenticationFilter(HttpSecurity http) { + super(new AntPathRequestMatcher("/api/login", "POST")); + setSecurityContextRepository(getSecurityContextRepository(http)); + } + + private SecurityContextRepository getSecurityContextRepository(HttpSecurity http) { + SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class); + if (securityContextRepository == null) { + securityContextRepository = new DelegatingSecurityContextRepository( + new RequestAttributeSecurityContextRepository(), new HttpSessionSecurityContextRepository()); + } + return securityContextRepository; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException { + + if (!HttpMethod.POST.name().equals(request.getMethod()) || !WebUtil.isAjax(request)) { + throw new IllegalArgumentException("Authentication method not supported"); + } + + AccountDto accountDto = objectMapper.readValue(request.getReader(), AccountDto.class); + + if (!StringUtils.hasText(accountDto.getUsername()) || !StringUtils.hasText(accountDto.getPassword())) { + throw new AuthenticationServiceException("Username or Password not provided"); + } + RestAuthenticationToken token = new RestAuthenticationToken(accountDto.getUsername(),accountDto.getPassword()); + + return this.getAuthenticationManager().authenticate(token); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/handler/FormAccessDeniedHandler.java b/src/main/java/io/security/springsecuritymaster/security/handler/FormAccessDeniedHandler.java new file mode 100644 index 00000000..b010b6e4 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/handler/FormAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package io.security.springsecuritymaster.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +public class FormAccessDeniedHandler implements AccessDeniedHandler { + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + private final String errorPage; + + public FormAccessDeniedHandler(String errorPage) { + this.errorPage = errorPage; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + + String deniedUrl = errorPage + "?exception=" + accessDeniedException.getMessage(); + redirectStrategy.sendRedirect(request, response, deniedUrl); + + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/handler/FormAuthenticationFailureHandler.java b/src/main/java/io/security/springsecuritymaster/security/handler/FormAuthenticationFailureHandler.java new file mode 100644 index 00000000..c3952d8f --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/handler/FormAuthenticationFailureHandler.java @@ -0,0 +1,42 @@ +package io.security.springsecuritymaster.security.handler; + +import io.security.springsecuritymaster.security.exception.SecretException; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component("failureHandler") +public class FormAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException exception) throws IOException, ServletException { + + String errorMessage = "Invalid Username or Password"; + + if(exception instanceof BadCredentialsException) { + errorMessage = "Invalid Username or Password"; + } + else if(exception instanceof UsernameNotFoundException) { + errorMessage = "User not exists"; + } + else if(exception instanceof CredentialsExpiredException) { + errorMessage = "Expired password"; + + }else if(exception instanceof SecretException) { + errorMessage = "Invalid Secret key"; + } + + setDefaultFailureUrl("/login?error=true&exception=" + errorMessage); + + super.onAuthenticationFailure(request,response,exception); + + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/handler/FormAuthenticationSuccessHandler.java b/src/main/java/io/security/springsecuritymaster/security/handler/FormAuthenticationSuccessHandler.java new file mode 100644 index 00000000..477b6194 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/handler/FormAuthenticationSuccessHandler.java @@ -0,0 +1,36 @@ +package io.security.springsecuritymaster.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.savedrequest.SavedRequest; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component("successHandler") +public class FormAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final RequestCache requestCache = new HttpSessionRequestCache(); + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + @Override + public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) throws IOException { + + setDefaultTargetUrl("/"); + + SavedRequest savedRequest = requestCache.getRequest(request, response); + + if(savedRequest!=null) { + String targetUrl = savedRequest.getRedirectUrl(); + redirectStrategy.sendRedirect(request, response, targetUrl); + } + else { + redirectStrategy.sendRedirect(request, response, getDefaultTargetUrl()); + } + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/handler/RestAccessDeniedHandler.java b/src/main/java/io/security/springsecuritymaster/security/handler/RestAccessDeniedHandler.java new file mode 100644 index 00000000..f9653c1b --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/handler/RestAccessDeniedHandler.java @@ -0,0 +1,25 @@ +package io.security.springsecuritymaster.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper mapper = new ObjectMapper(); + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.getWriter().write(this.mapper.writeValueAsString(HttpServletResponse.SC_FORBIDDEN)); + + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/handler/RestAuthenticationFailureHandler.java b/src/main/java/io/security/springsecuritymaster/security/handler/RestAuthenticationFailureHandler.java new file mode 100644 index 00000000..dd0af9e7 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/handler/RestAuthenticationFailureHandler.java @@ -0,0 +1,30 @@ +package io.security.springsecuritymaster.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component("restFailureHandler") +public class RestAuthenticationFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { + + ObjectMapper mapper = new ObjectMapper(); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + if (exception instanceof BadCredentialsException) { + mapper.writeValue(response.getWriter(), "Invalid username or password"); + } + mapper.writeValue(response.getWriter(), "Authentication failed"); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/handler/RestAuthenticationSuccessHandler.java b/src/main/java/io/security/springsecuritymaster/security/handler/RestAuthenticationSuccessHandler.java new file mode 100644 index 00000000..1f80a5f7 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/handler/RestAuthenticationSuccessHandler.java @@ -0,0 +1,40 @@ +package io.security.springsecuritymaster.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.security.springsecuritymaster.domain.dto.AccountContext; +import io.security.springsecuritymaster.domain.dto.AccountDto; +import io.security.springsecuritymaster.domain.entity.Account; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.WebAttributes; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import java.io.IOException; + +@Component("restSuccessHandler") +public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + + ObjectMapper mapper = new ObjectMapper(); + + AccountDto accountDto = (AccountDto) authentication.getPrincipal(); + response.setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + accountDto.setPassword(null); + mapper.writeValue(response.getWriter(), accountDto); + + clearAuthenticationAttributes(request); + } + protected final void clearAuthenticationAttributes(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); + } +} \ No newline at end of file diff --git a/src/main/java/io/security/springsecuritymaster/security/provider/FormAuthenticationProvider.java b/src/main/java/io/security/springsecuritymaster/security/provider/FormAuthenticationProvider.java new file mode 100644 index 00000000..948c1fda --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/provider/FormAuthenticationProvider.java @@ -0,0 +1,47 @@ +package io.security.springsecuritymaster.security.provider; + +import io.security.springsecuritymaster.domain.dto.AccountContext; +import io.security.springsecuritymaster.security.details.FormWebAuthenticationDetails; +import io.security.springsecuritymaster.security.exception.SecretException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component("authenticationProvider") +@RequiredArgsConstructor +public class FormAuthenticationProvider implements AuthenticationProvider { + + private final UserDetailsService userDetailsService; + private final PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + String loginId = authentication.getName(); + String password = (String) authentication.getCredentials(); + + AccountContext accountContext = (AccountContext)userDetailsService.loadUserByUsername(loginId); + + if (!passwordEncoder.matches(password, accountContext.getPassword())) { + throw new BadCredentialsException("Invalid password"); + } + + String secretKey = ((FormWebAuthenticationDetails) authentication.getDetails()).getSecretKey(); + if (secretKey == null || !secretKey.equals("secret")) { + throw new SecretException("Invalid Secret"); + } + + return new UsernamePasswordAuthenticationToken(accountContext.getAccountDto(), null, accountContext.getAuthorities()); + } + + @Override + public boolean supports(Class authentication) { + return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class); + } +} \ No newline at end of file diff --git a/src/main/java/io/security/springsecuritymaster/security/provider/RestAuthenticationProvider.java b/src/main/java/io/security/springsecuritymaster/security/provider/RestAuthenticationProvider.java new file mode 100644 index 00000000..15c40a13 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/provider/RestAuthenticationProvider.java @@ -0,0 +1,39 @@ +package io.security.springsecuritymaster.security.provider; + +import io.security.springsecuritymaster.domain.dto.AccountContext; +import io.security.springsecuritymaster.security.token.RestAuthenticationToken; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component("restAuthenticationProvider") +@RequiredArgsConstructor +public class RestAuthenticationProvider implements AuthenticationProvider { + + private final UserDetailsService userDetailsService; + private final PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + String loginId = authentication.getName(); + String password = (String) authentication.getCredentials(); + AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(loginId); + + if(!passwordEncoder.matches(password, accountContext.getPassword())){ + throw new BadCredentialsException("Invalid password"); + } + + return new RestAuthenticationToken(accountContext.getAuthorities(), accountContext.getAccountDto(), null); + } + + @Override + public boolean supports(Class authentication) { + return authentication.isAssignableFrom(RestAuthenticationToken.class); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/service/FormUserDetailsService.java b/src/main/java/io/security/springsecuritymaster/security/service/FormUserDetailsService.java new file mode 100644 index 00000000..e7c0b608 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/service/FormUserDetailsService.java @@ -0,0 +1,37 @@ +package io.security.springsecuritymaster.security.service; + +import io.security.springsecuritymaster.domain.dto.AccountContext; +import io.security.springsecuritymaster.domain.dto.AccountDto; +import io.security.springsecuritymaster.domain.entity.Account; +import io.security.springsecuritymaster.users.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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 java.util.List; + +@Service("userDetailsService") +@RequiredArgsConstructor +public class FormUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + Account account = userRepository.findByUsername(username); + if (account == null) { + throw new UsernameNotFoundException("No user found with username: " + username); + } + List authorities = List.of(new SimpleGrantedAuthority(account.getRoles())); + ModelMapper mapper = new ModelMapper(); + AccountDto accountDto = mapper.map(account, AccountDto.class); + + return new AccountContext(accountDto, authorities); + } +} diff --git a/src/main/java/io/security/springsecuritymaster/security/token/RestAuthenticationToken.java b/src/main/java/io/security/springsecuritymaster/security/token/RestAuthenticationToken.java new file mode 100644 index 00000000..6db13db4 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/security/token/RestAuthenticationToken.java @@ -0,0 +1,36 @@ +package io.security.springsecuritymaster.security.token; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class RestAuthenticationToken extends AbstractAuthenticationToken { + + private final Object principal; + private final Object credentials; + + public RestAuthenticationToken(Collection authorities, Object principal, Object credentials) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(true); + } + + public RestAuthenticationToken(Object principal, Object credentials) { + super(null); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(false); + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } +} diff --git a/src/main/java/io/security/springsecuritymaster/users/controller/LoginController.java b/src/main/java/io/security/springsecuritymaster/users/controller/LoginController.java new file mode 100644 index 00000000..e8039ae0 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/users/controller/LoginController.java @@ -0,0 +1,54 @@ +package io.security.springsecuritymaster.users.controller; + +import io.security.springsecuritymaster.domain.dto.AccountDto; +import io.security.springsecuritymaster.domain.entity.Account; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class LoginController { + @GetMapping(value="/login") + public String login(@RequestParam(value = "error", required = false) String error, + @RequestParam(value = "exception", required = false) String exception, Model model){ + model.addAttribute("error",error); + model.addAttribute("exception",exception); + return "login/login"; + } + + @GetMapping(value="/api/login") + public String restLogin(){ + return "rest/login"; + } + + @GetMapping(value="/signup") + public String signup() { + return "login/signup"; + } + + @GetMapping(value = "/logout") + public String logout(HttpServletRequest request, HttpServletResponse response) { + Authentication authentication = SecurityContextHolder.getContextHolderStrategy().getContext().getAuthentication(); + if (authentication != null) { + new SecurityContextLogoutHandler().logout(request, response, authentication); + } + + return "redirect:/login"; + } + + @GetMapping(value="/denied") + public String accessDenied(@RequestParam(value = "exception", required = false) String exception, @AuthenticationPrincipal AccountDto accountDto, Model model) { + + model.addAttribute("username", accountDto.getUsername()); + model.addAttribute("exception", exception); + + return "login/denied"; + } +} diff --git a/src/main/java/io/security/springsecuritymaster/users/controller/RestApiController.java b/src/main/java/io/security/springsecuritymaster/users/controller/RestApiController.java new file mode 100644 index 00000000..6b9ebd10 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/users/controller/RestApiController.java @@ -0,0 +1,40 @@ +package io.security.springsecuritymaster.users.controller; + +import io.security.springsecuritymaster.domain.dto.AccountDto; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +public class RestApiController { + @GetMapping(value="/user") + public AccountDto restUser(@AuthenticationPrincipal AccountDto accountDto) { + return accountDto; + } + + @GetMapping(value="/manager") + public AccountDto restManager(@AuthenticationPrincipal AccountDto accountDto) { + return accountDto; + } + + @GetMapping(value="/admin") + public AccountDto restAdmin(@AuthenticationPrincipal AccountDto accountDto) { + return accountDto; + } + @GetMapping(value = "/logout") + public String logout(HttpServletRequest request, HttpServletResponse response) { + Authentication authentication = SecurityContextHolder.getContextHolderStrategy().getContext().getAuthentication(); + if (authentication != null) { + new SecurityContextLogoutHandler().logout(request, response, authentication); + } + + return "logout"; + } +} diff --git a/src/main/java/io/security/springsecuritymaster/users/controller/UserController.java b/src/main/java/io/security/springsecuritymaster/users/controller/UserController.java new file mode 100644 index 00000000..5444c36c --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/users/controller/UserController.java @@ -0,0 +1,29 @@ +package io.security.springsecuritymaster.users.controller; + +import io.security.springsecuritymaster.domain.dto.AccountDto; +import io.security.springsecuritymaster.domain.entity.Account; +import io.security.springsecuritymaster.users.service.UserService; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final PasswordEncoder passwordEncoder; + + @PostMapping(value="/signup") + public String signup(AccountDto accountDto) { + + ModelMapper mapper = new ModelMapper(); + Account account = mapper.map(accountDto, Account.class); + account.setPassword(passwordEncoder.encode(accountDto.getPassword())); + userService.createUser(account); + + return "redirect:/"; + } +} diff --git a/src/main/java/io/security/springsecuritymaster/users/repository/UserRepository.java b/src/main/java/io/security/springsecuritymaster/users/repository/UserRepository.java new file mode 100644 index 00000000..da0040f1 --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/users/repository/UserRepository.java @@ -0,0 +1,9 @@ +package io.security.springsecuritymaster.users.repository; + +import io.security.springsecuritymaster.domain.entity.Account; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + Account findByUsername(String username); + +} diff --git a/src/main/java/io/security/springsecuritymaster/users/service/UserService.java b/src/main/java/io/security/springsecuritymaster/users/service/UserService.java new file mode 100644 index 00000000..d1d0c9cb --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/users/service/UserService.java @@ -0,0 +1,22 @@ +package io.security.springsecuritymaster.users.service; + +import io.security.springsecuritymaster.domain.entity.Account; +import io.security.springsecuritymaster.users.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Transactional + public void createUser(Account account){ + userRepository.save(account); + } + +} \ No newline at end of file diff --git a/src/main/java/io/security/springsecuritymaster/util/WebUtil.java b/src/main/java/io/security/springsecuritymaster/util/WebUtil.java new file mode 100644 index 00000000..02dd2acc --- /dev/null +++ b/src/main/java/io/security/springsecuritymaster/util/WebUtil.java @@ -0,0 +1,19 @@ +package io.security.springsecuritymaster.util; + +import jakarta.servlet.http.HttpServletRequest; + +public class WebUtil { + private static final String XML_HTTP_REQUEST = "XMLHttpRequest"; + private static final String X_REQUESTED_WITH = "X-Requested-With"; + + private static final String CONTENT_TYPE = "Content-type"; + private static final String CONTENT_TYPE_JSON = "application/json"; + + public static boolean isAjax(HttpServletRequest request) { + return XML_HTTP_REQUEST.equals(request.getHeader(X_REQUESTED_WITH)); + } + + public static boolean isContentTypeJson(HttpServletRequest request) { + return request.getHeader(CONTENT_TYPE).contains(CONTENT_TYPE_JSON); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 63008877..1951309e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,18 @@ spring: + datasource: + url: jdbc:postgresql://localhost:5432/springboot + username: postgres + password: pass + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + jdbc: + lob: + non_contextual_creation: true + thymeleaf: cache: false \ No newline at end of file diff --git a/src/main/resources/static/images/spring-security-project.png b/src/main/resources/static/images/spring-security-project.png new file mode 100644 index 00000000..f7518db2 Binary files /dev/null and b/src/main/resources/static/images/spring-security-project.png differ diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 99aca55e..dda2f335 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -63,17 +63,17 @@ -
+
-
+
- + \ No newline at end of file diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 184cc8f4..a9c2fa26 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -63,17 +63,17 @@ -
+
-
+
- + \ No newline at end of file diff --git a/src/main/resources/templates/layout/header.html b/src/main/resources/templates/layout/header.html index ae5852e0..eb64c756 100644 --- a/src/main/resources/templates/layout/header.html +++ b/src/main/resources/templates/layout/header.html @@ -1,13 +1,19 @@ + +