From 53d33d12af551efcffd9f7b042351bb1564b26ee Mon Sep 17 00:00:00 2001 From: GiaBaor Date: Wed, 18 Dec 2024 14:05:52 +0700 Subject: [PATCH 1/3] feature: Bump spring boot version to 3.4.0 --- sep490-idp/build.gradle | 2 +- sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sep490-idp/build.gradle b/sep490-idp/build.gradle index 13fc4a30..a3b9b267 100644 --- a/sep490-idp/build.gradle +++ b/sep490-idp/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.5' + id 'org.springframework.boot' version '3.4.0' id 'io.spring.dependency-management' version '1.1.6' id "com.github.node-gradle.node" version "7.1.0" } diff --git a/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java b/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java index 6294839f..18103351 100644 --- a/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java +++ b/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java @@ -60,6 +60,7 @@ CorsConfigurationSource corsConfigurationSource() { public SecurityFilterChain asFilterChain(HttpSecurity http) throws Exception { // reference: https://docs.spring.io/spring-authorization-server/reference/guides/how-to-userinfo.html + // TODO: solve deprecated OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); From 112118e7ff42f4bd9cdc73c7f831726c787e2385 Mon Sep 17 00:00:00 2001 From: GiaBaor Date: Sun, 22 Dec 2024 00:51:04 +0700 Subject: [PATCH 2/3] feature: Integrate with passkeys --- sep490-idp/build.gradle | 1 + .../sep490/idp/configs/SecurityConfig.java | 13 +- .../sep490/idp/controller/MainController.java | 33 +++- .../idp/controller/PasskeyController.java | 52 +++++++ .../idp/dto/CredentialsRegistration.java | 17 +++ .../idp/dto/CredentialsVerification.java | 7 + .../sep490/idp/entity/UserAuthenticator.java | 45 ++++++ .../UserAuthenticatorRepository.java | 19 +++ .../idp/security/UserAuthenticationToken.java | 51 +++++++ .../idp/service/AuthenticatorService.java | 18 +++ .../impl/AuthenticatorServiceImpl.java | 142 ++++++++++++++++++ .../sep490/idp/service/impl/LoginService.java | 23 +++ .../java/sep490/idp/utils/SecurityUtils.java | 28 +++- .../V0.0.1.1__UserAuthenticators.sql | 14 ++ .../src/main/resources/static/favicon.ico | Bin 0 -> 15406 bytes .../resources/templates/account-page.html | 128 ++++++++++++++++ .../src/main/resources/templates/login.html | 33 +++- 17 files changed, 609 insertions(+), 15 deletions(-) create mode 100644 sep490-idp/src/main/java/sep490/idp/controller/PasskeyController.java create mode 100644 sep490-idp/src/main/java/sep490/idp/dto/CredentialsRegistration.java create mode 100644 sep490-idp/src/main/java/sep490/idp/dto/CredentialsVerification.java create mode 100644 sep490-idp/src/main/java/sep490/idp/entity/UserAuthenticator.java create mode 100644 sep490-idp/src/main/java/sep490/idp/repository/UserAuthenticatorRepository.java create mode 100644 sep490-idp/src/main/java/sep490/idp/security/UserAuthenticationToken.java create mode 100644 sep490-idp/src/main/java/sep490/idp/service/AuthenticatorService.java create mode 100644 sep490-idp/src/main/java/sep490/idp/service/impl/AuthenticatorServiceImpl.java create mode 100644 sep490-idp/src/main/java/sep490/idp/service/impl/LoginService.java create mode 100644 sep490-idp/src/main/resources/db/migration/V0.0.1.1__UserAuthenticators.sql create mode 100644 sep490-idp/src/main/resources/static/favicon.ico create mode 100644 sep490-idp/src/main/resources/templates/account-page.html diff --git a/sep490-idp/build.gradle b/sep490-idp/build.gradle index a3b9b267..c467b5cc 100644 --- a/sep490-idp/build.gradle +++ b/sep490-idp/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation 'org.springframework.session:spring-session-core' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + implementation 'com.webauthn4j:webauthn4j-core:0.28.3.RELEASE' // Database implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java b/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java index 18103351..0f8bc963 100644 --- a/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java +++ b/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java @@ -82,10 +82,11 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws http.cors(cors -> cors.configurationSource(corsConfigurationSource())); http.authorizeHttpRequests( - c -> c - .requestMatchers("/css/**", "/img/**", "/js/**").permitAll() - .requestMatchers(antMatcher("/signup"), antMatcher("/login")).permitAll() - .anyRequest().authenticated()); + c -> c + .requestMatchers("/css/**", "/img/**", "/js/**", "/favicon.ico").permitAll() + .requestMatchers(antMatcher("/signup"), antMatcher("/login")).permitAll() + .requestMatchers(antMatcher("/passkey/login")).permitAll() + .anyRequest().authenticated()); http.formLogin(form -> form.loginPage("/login") .usernameParameter("email") @@ -94,6 +95,10 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws .failureHandler(authenticationFailureHandler()) .permitAll()); + // Passkey Configuration + http.webAuthn(passkeys -> + passkeys.rpName("SEP490 IDP").rpId("localhost").allowedOrigins("http://localhost:8080")); + return http.build(); } diff --git a/sep490-idp/src/main/java/sep490/idp/controller/MainController.java b/sep490-idp/src/main/java/sep490/idp/controller/MainController.java index 1ab736b7..c61245dd 100644 --- a/sep490-idp/src/main/java/sep490/idp/controller/MainController.java +++ b/sep490-idp/src/main/java/sep490/idp/controller/MainController.java @@ -2,25 +2,30 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; -import sep490.idp.dto.LoginDTO; -import sep490.idp.dto.SignupDTO; -import sep490.idp.dto.SignupResult; +import org.springframework.web.bind.annotation.*; +import sep490.idp.dto.*; +import sep490.idp.repository.UserAuthenticatorRepository; +import sep490.idp.security.UserContextData; import sep490.idp.service.UserService; +import java.util.UUID; + @Controller @RequiredArgsConstructor +@Slf4j +@SessionAttributes("challenge") public class MainController { public static final String ERROR_MSG = "errorMsg"; private final UserService userService; + private final UserAuthenticatorRepository authenticatorRepository; + @GetMapping("/") public String homePage() { @@ -29,6 +34,7 @@ public String homePage() { @GetMapping("/login") public String loginPage(@RequestParam(value = "error", required = false) String error, Model model) { + model.addAttribute("challenge", UUID.randomUUID().toString()); model.addAttribute("loginDTO", new LoginDTO()); if (error != null) { model.addAttribute("errorKey", error); @@ -54,6 +60,17 @@ public String processSignup(@Valid @ModelAttribute("signupDTO") SignupDTO signup @GetMapping("/success") public String success() { - return "success"; + return "redirect:/account"; + } + + @GetMapping("/account") + public String accountPage(@AuthenticationPrincipal UserContextData userContextData, Model model) { + model.addAttribute("challenge", UUID.randomUUID().toString()); + var authenticators = authenticatorRepository.findUserAuthenticatorByUser(userContextData.getUserEntity()); + model.addAttribute("userId", userContextData.getUserEntity().getId()); + model.addAttribute("email", userContextData.getUserEntity().getEmail()); + model.addAttribute("authenticators", authenticators); + + return "account-page"; } } diff --git a/sep490-idp/src/main/java/sep490/idp/controller/PasskeyController.java b/sep490-idp/src/main/java/sep490/idp/controller/PasskeyController.java new file mode 100644 index 00000000..da31c63d --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/controller/PasskeyController.java @@ -0,0 +1,52 @@ +package sep490.idp.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.support.SessionStatus; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import sep490.idp.dto.CredentialsRegistration; +import sep490.idp.dto.CredentialsVerification; +import sep490.idp.security.UserContextData; +import sep490.idp.service.AuthenticatorService; +import sep490.idp.service.impl.LoginService; + +@Controller +@RequiredArgsConstructor +@Slf4j +public class PasskeyController { + + private final AuthenticatorService authenticatorService; + private final LoginService loginService; + + @PostMapping("/passkey/login") + public String login(@RequestBody CredentialsVerification verification, SessionStatus sessionStatus) { + var user = authenticatorService.authenticate(verification); + loginService.login(user); + + sessionStatus.setComplete(); //Remove challenge in session + return "redirect:/account"; + } + + @PostMapping("/passkey/register") + public String register(@RequestBody CredentialsRegistration credentials, + @AuthenticationPrincipal UserContextData user) { + authenticatorService.saveCredentials(credentials, user.getUserEntity()); + return "redirect:/account"; + } + + @PostMapping("/passkey/delete") + public String login(@NotNull @RequestParam("credential-id") String credentialId, @AuthenticationPrincipal UserContextData userContextData, + RedirectAttributes redirectAttributes) { + if (authenticatorService.deleteCredential(userContextData.getUserEntity(), credentialId)) { + redirectAttributes.addFlashAttribute("alert", "Passkey deleted."); + } + return "redirect:/account"; + } + +} diff --git a/sep490-idp/src/main/java/sep490/idp/dto/CredentialsRegistration.java b/sep490-idp/src/main/java/sep490/idp/dto/CredentialsRegistration.java new file mode 100644 index 00000000..299380f3 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/dto/CredentialsRegistration.java @@ -0,0 +1,17 @@ +package sep490.idp.dto; + +public record CredentialsRegistration(String name, AuthenticatorCredentials credentials) { + + // Reference: https://webauthn.guide/#registration + // This object is return from Client-Authenticator such as WindowHello + + // ID: newly generated credential ID as base-64 encoded string + // ClientDataJson: The data passed from browser to authenticator to create new credential as UTF-8 byte array + // AttestationObject: The object contains credential public key ... + public record AuthenticatorCredentials(String id, Response response) { + } + + public record Response(String attestationObject, String clientDataJSON) { + } +} + diff --git a/sep490-idp/src/main/java/sep490/idp/dto/CredentialsVerification.java b/sep490-idp/src/main/java/sep490/idp/dto/CredentialsVerification.java new file mode 100644 index 00000000..7eea02e0 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/dto/CredentialsVerification.java @@ -0,0 +1,7 @@ +package sep490.idp.dto; + +public record CredentialsVerification(String id, Response response) { + public record Response(String authenticatorData, String clientDataJSON, String signature, String userHandle) { + } +} + diff --git a/sep490-idp/src/main/java/sep490/idp/entity/UserAuthenticator.java b/sep490-idp/src/main/java/sep490/idp/entity/UserAuthenticator.java new file mode 100644 index 00000000..ee83381a --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/entity/UserAuthenticator.java @@ -0,0 +1,45 @@ +package sep490.idp.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.Objects; + +@Entity +@Table(name = "authenticator") +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Setter +public class UserAuthenticator extends AbstractBaseEntity{ + + @Column(name = "credential_id", nullable = false, unique = true) + private String credentialId; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(name = "credential_name") + private String credentialName; + + @Column(name = "attestation_object", nullable = false) + private byte[] attestationObject; + + @Column(name = "sign_count") + private long signCount; + + @Override + public boolean equals(Object o) { + if (this == o) { return true; } + if (!(o instanceof UserAuthenticator that)) { return false; } + if (!super.equals(o)) { return false; } + return Objects.equals(credentialId, that.credentialId); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), credentialId); + } +} diff --git a/sep490-idp/src/main/java/sep490/idp/repository/UserAuthenticatorRepository.java b/sep490-idp/src/main/java/sep490/idp/repository/UserAuthenticatorRepository.java new file mode 100644 index 00000000..c2c4118b --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/repository/UserAuthenticatorRepository.java @@ -0,0 +1,19 @@ +package sep490.idp.repository; + +import org.springframework.data.repository.CrudRepository; +import sep490.idp.entity.UserAuthenticator; +import sep490.idp.entity.UserEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserAuthenticatorRepository extends CrudRepository { + + Optional findByCredentialId(String credentialId); + + List findUserAuthenticatorByUser(UserEntity user); + + long deleteByCredentialIdAndUser(String id, UserEntity user); + +} \ No newline at end of file diff --git a/sep490-idp/src/main/java/sep490/idp/security/UserAuthenticationToken.java b/sep490-idp/src/main/java/sep490/idp/security/UserAuthenticationToken.java new file mode 100644 index 00000000..9e2bf887 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/security/UserAuthenticationToken.java @@ -0,0 +1,51 @@ +package sep490.idp.security; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.springframework.security.authentication.AbstractAuthenticationToken; + +import java.io.Serializable; + +public class UserAuthenticationToken extends AbstractAuthenticationToken implements Serializable { + + private final UserContextData userContextData; + + public UserAuthenticationToken(UserContextData userContextData) { + super(null); + this.userContextData = userContextData; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return userContextData; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public void setAuthenticated(boolean authenticated) { + throw new RuntimeException("Can't touch this 🎶"); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (!(o instanceof UserAuthenticationToken that)) return false; + + return new EqualsBuilder().appendSuper(super.equals(o)).append(userContextData, that.userContextData).isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37).appendSuper(super.hashCode()).append(userContextData).toHashCode(); + } +} diff --git a/sep490-idp/src/main/java/sep490/idp/service/AuthenticatorService.java b/sep490-idp/src/main/java/sep490/idp/service/AuthenticatorService.java new file mode 100644 index 00000000..9810460e --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/service/AuthenticatorService.java @@ -0,0 +1,18 @@ +package sep490.idp.service; + +import sep490.idp.dto.CredentialsRegistration; +import sep490.idp.dto.CredentialsVerification; +import sep490.idp.entity.UserEntity; + +/** + * Interface for managing the registration and verification of authenticators. + * Reference: https://webauthn4j.github.io/webauthn4j/en/#feature + */ +public interface AuthenticatorService { + + void saveCredentials(CredentialsRegistration registration, UserEntity user); + + UserEntity authenticate(CredentialsVerification verification); + + boolean deleteCredential(UserEntity user, String credentialId); +} diff --git a/sep490-idp/src/main/java/sep490/idp/service/impl/AuthenticatorServiceImpl.java b/sep490-idp/src/main/java/sep490/idp/service/impl/AuthenticatorServiceImpl.java new file mode 100644 index 00000000..50550401 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/service/impl/AuthenticatorServiceImpl.java @@ -0,0 +1,142 @@ +package sep490.idp.service.impl; + + +import com.webauthn4j.WebAuthnManager; +import com.webauthn4j.converter.AttestationObjectConverter; +import com.webauthn4j.converter.exception.DataConversionException; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.credential.CredentialRecordImpl; +import com.webauthn4j.data.*; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.verifier.exception.VerificationException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; +import sep490.idp.dto.CredentialsRegistration; +import sep490.idp.dto.CredentialsVerification; +import sep490.idp.entity.UserAuthenticator; +import sep490.idp.entity.UserEntity; +import sep490.idp.repository.UserAuthenticatorRepository; +import sep490.idp.service.AuthenticatorService; + +import java.util.Base64; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(rollbackOn = Throwable.class) +public class AuthenticatorServiceImpl implements AuthenticatorService { + + private final UserAuthenticatorRepository repository; + private final HttpServletRequest request; + private final HttpSession httpSession; + private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager(); + private final AttestationObjectConverter attestationConverter = new AttestationObjectConverter(new ObjectConverter()); + + + public void saveCredentials(CredentialsRegistration registration, UserEntity user) { + try { + RegistrationData registrationData = getRegistrationData(registration); + + verifyRegistrationData(registrationData); + + UserAuthenticator serializedAuthenticator = createSerializedAuthenticator(registration, user); + repository.save(serializedAuthenticator); + } catch (DataConversionException | VerificationException e) { + log.error(e.getMessage(), e); + } catch (Exception e) { + throw new RuntimeException("Unexpected Error", e); + } + } + + @Override + public UserEntity authenticate(CredentialsVerification verification) { + try { + UserAuthenticator userAuthenticator = repository.findByCredentialId(verification.id()).orElseThrow(() -> new BadCredentialsException("Unknown credentials")); + AttestationObject attestation = attestationConverter.convert(userAuthenticator.getAttestationObject()); + CredentialRecordImpl authenticator = new CredentialRecordImpl(attestation, null, null, null); + + AuthenticationRequest authenticationRequest = getAuthenticationRequest(verification); + AuthenticationParameters authenticationParameters = + new AuthenticationParameters(buildServerPropertyFromSessionChallenge(), authenticator, null, true, true); + + AuthenticationData authenticationData = webAuthnManager.verify(authenticationRequest, authenticationParameters); + UserEntity user = userAuthenticator.getUser(); + + checkSignCount(userAuthenticator, authenticationData.getAuthenticatorData().getSignCount()); + return user; + } catch (Exception e) { + log.error(e.getMessage(), e); + throw new BadCredentialsException("Authentication failed", e); + } + } + + public boolean deleteCredential(UserEntity user, String credentialId) { + return repository.deleteByCredentialIdAndUser(credentialId, user) > 0; + } + + private void checkSignCount(UserAuthenticator userAuthenticator, long signCount) { + if (userAuthenticator.getSignCount() != signCount && signCount != 0) { + log.warn("A cloned authenticator may exist!"); + } + } + + private String getRpId() { + return UriComponentsBuilder.fromUriString(request.getRequestURL().toString()).build().getHost(); + } + + private Origin getOrigin() { + var origin = UriComponentsBuilder.fromUriString(request.getRequestURL().toString()).replacePath(null).toUriString(); + return new Origin(origin); + } + + private AuthenticationRequest getAuthenticationRequest(CredentialsVerification verification) { + byte[] clientDataJSON = Base64.getUrlDecoder().decode(verification.response().clientDataJSON()); + byte[] authenticatorData = Base64.getUrlDecoder().decode(verification.response().authenticatorData()); + byte[] signature = Base64.getUrlDecoder().decode(verification.response().signature()); + byte[] userHandle = Base64.getUrlDecoder().decode(verification.response().userHandle()); + + return new AuthenticationRequest(verification.id().getBytes(),userHandle, authenticatorData, clientDataJSON, null, signature); + } + + private RegistrationData getRegistrationData(CredentialsRegistration registration) throws DataConversionException { + var registrationRequest = new RegistrationRequest( + Base64.getUrlDecoder().decode(registration.credentials().response().attestationObject()), + Base64.getUrlDecoder().decode(registration.credentials().response().clientDataJSON())); + + return webAuthnManager.parse(registrationRequest); + } + + private ServerProperty buildServerPropertyFromSessionChallenge() { + String challenge = httpSession.getAttribute("challenge").toString(); + return new ServerProperty(getOrigin(), getRpId(), new DefaultChallenge(challenge.getBytes()), null); + } + + private void verifyRegistrationData(RegistrationData registrationData) throws VerificationException { + var pubKeyCredParam = new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256); + var registrationParameters = + new RegistrationParameters(buildServerPropertyFromSessionChallenge(), List.of(pubKeyCredParam), false, true); + + webAuthnManager.verify(registrationData, registrationParameters); + } + + private UserAuthenticator createSerializedAuthenticator(CredentialsRegistration registration, UserEntity user) { + return UserAuthenticator.builder() + .credentialId(registration.credentials().id()) + .user(user) + .credentialName(registration.name()) + .attestationObject(Base64.getUrlDecoder().decode(registration.credentials().response().attestationObject())) + .signCount(0) + .build(); + } +} diff --git a/sep490-idp/src/main/java/sep490/idp/service/impl/LoginService.java b/sep490-idp/src/main/java/sep490/idp/service/impl/LoginService.java new file mode 100644 index 00000000..cc134554 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/service/impl/LoginService.java @@ -0,0 +1,23 @@ +package sep490.idp.service.impl; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import sep490.idp.security.UserAuthenticationToken; +import sep490.idp.entity.UserEntity; +import sep490.idp.security.UserContextData; +import sep490.idp.utils.SecurityUtils; + +@Component +@RequiredArgsConstructor +public class LoginService { + + private final HttpServletRequest request; + private final HttpServletResponse response; + + public void login(UserEntity user) { + var auth = new UserAuthenticationToken(new UserContextData(user)); + SecurityUtils.storeAuthenticationToContext(auth, request, response); + } +} diff --git a/sep490-idp/src/main/java/sep490/idp/utils/SecurityUtils.java b/sep490-idp/src/main/java/sep490/idp/utils/SecurityUtils.java index a6b752a3..186bfc32 100644 --- a/sep490-idp/src/main/java/sep490/idp/utils/SecurityUtils.java +++ b/sep490-idp/src/main/java/sep490/idp/utils/SecurityUtils.java @@ -1,16 +1,24 @@ package sep490.idp.utils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import sep490.idp.security.UserAuthenticationToken; import sep490.idp.security.UserContextData; import java.util.Optional; public final class SecurityUtils { - + + private static final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + private SecurityUtils() { // Utility class. No instantiation allowed. } - + public static Optional getUserContextData() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (principal instanceof UserContextData currentUser) { @@ -18,4 +26,20 @@ public static Optional getUserContextData() { } return Optional.empty(); } + + public static void storeAuthenticationToContext(UserAuthenticationToken authenticationToken, + HttpServletRequest request, HttpServletResponse response) { + if (authenticationToken == null || request == null || response == null) { + throw new IllegalArgumentException("Parameters cannot be null"); + } + try { + SecurityContext newContext = SecurityContextHolder.createEmptyContext(); + newContext.setAuthentication(authenticationToken); + SecurityContextHolder.setContext(newContext); + securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response); + } catch (Exception e) { + SecurityContextHolder.clearContext(); + throw new SecurityException("Failed to store authentication context", e); + } + } } diff --git a/sep490-idp/src/main/resources/db/migration/V0.0.1.1__UserAuthenticators.sql b/sep490-idp/src/main/resources/db/migration/V0.0.1.1__UserAuthenticators.sql new file mode 100644 index 00000000..90f849fc --- /dev/null +++ b/sep490-idp/src/main/resources/db/migration/V0.0.1.1__UserAuthenticators.sql @@ -0,0 +1,14 @@ +CREATE TABLE authenticator +( + id UUID NOT NULL DEFAULT gen_random_uuid(), + version INTEGER NOT NULL, + credential_id VARCHAR(255) NOT NULL, + credential_name VARCHAR(255), + attestation_object bytea NOT NULL, + sign_count bigint, + user_id UUID NOT NULL +); +ALTER TABLE authenticator + ADD CONSTRAINT authenticator_pk PRIMARY KEY (id), + ADD CONSTRAINT authenticator_credential_id_unique UNIQUE (credential_id), + ADD CONSTRAINT authenticator_user_id_fk FOREIGN KEY (user_id) REFERENCES users (id); \ No newline at end of file diff --git a/sep490-idp/src/main/resources/static/favicon.ico b/sep490-idp/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..011ceabdc50cf7ef0b4ac5bf2394fdc203a0dcda GIT binary patch literal 15406 zcmeHOdz2N$nZIM$jmg>Fm}B<1YuvMVPEM92MiCGYP@WF3Ai;nk!6yb0(YORf0zMc8 zaX^EjhKE8_Py~c1j{t%=$YYq9+s(Z0y?uu}b1#T7yGeGF<(R0eq4xKy>guk3&0HV* z*XC+ZSAX?Ae&1JLJ-Vt%r7BXNOPzIA3is}*>D^MPzNu8Idw2ERTxRq5H41*Nc%OAi6hdmRq!g7@bu9}|1GZxVZUY?5UkhxJ~;Ne_``UHL*& z-FfY~^zVq-=ZoM+9cjGMzbiD?ok#Hs{Dp z&bV1Twzx`Utb@LOC%uIjN-b6o_lsYhs?~(nx#E^lPh~5`|LtecgzhT`oxK>GB)W^D^m-Q9(2X;#w?T$UG z=-7`i-nZN|S@Kbid^b#t;Bu_D#2asPV;&cs z^mVfRz>k>7ddW{2Tr6vOT`uMtuTvdBW>Tt@O|OzVpj+|kTnSuj)AouRBU`-~qR^3CagLAxv~I+la@egN4IN;%6Z=B#C` zlXjI!8aISLS&`#RcU(hoRmc(w3BL;0*Mw@wD?Ex1zt!Ux+@$J3m#e4@K2Z}{H!D-Bl7QN*gK_i zU*>6SPLqeco@iVFcC;yd*KFo9L-NkAtuJEid`af6>#Oox^wST!nm0?lvf(-L%EsqVzEjekcywXNzB|U`?BmpFVvuD5 z34!V8u)hSqGf~@0S9>-sTHX1|%n8z1r^Y1Du7L+SJFG87Fc<_OL`6q7y)DUhgwAo? z9m`p#$KY^T%5nUL?$fYgm<$w4dFu|vtMLbQ$~xT#j5#EZ9-wI%+-W0V)J0Rw9sOs> zyes9jPx>z9ZP9RWkJ$9WGh+MZb)t~rJg0DCO$ORpSw|jCkKC2McWFztyB`X~W?k%D z2^+h%ZV)$KGZAz7H&xyv-*3O=M$gWzQ?K>teJ=i{m~!paVmJDUXFuQvFvFG^gM72> zYai*prt|7pYrS~jp1Y)7y@rtM71$@1mh_wA=#d)Fwyxuy^;{!ShsL4xBELV%H`Sf5 zF?3|@D#fG64GZg#S}|>kTKkxKb(#7zSkpB(>=(Uzs`ZbqGw;-qZ$9E-&{$|w+D8+F z$2hj_v>PXT>n6SKGh;Ae+-Ohd__0?6#v_xDSZ3UOz3Q9H7q=}if2L{QvaG)XrVmX2 zs~?;f8&m4)<*xNsJ^6@dfBCX%k6+nV>r}}P8QvdVbicp7*X!Hj(7s=K7-+ND(t1uQ)9??{ zz8jYx-#*pzMcXs)1N!z9Z|wSoTFbfpkMjF=ZV{{_Z#e#P?G5@}|8`5jK5dtZHD_W? zhTOMA8~hm9@8U%JjLSx>-6dYbwtn8Q_G!_Nv4gF+>#|FGd2yr7QmN)l<^beRP)FUH zyJF?2Yf%4-#pV}Qd+S|oQ{Ls;SmoBs7@wj(_-)y6;5Ci^gr0mb9&Bb_at_xqG~+n= z>T%-W>)W+lB=!L;m?IDD-6no8@yd`dJ;Xq?5Bs&`hfO{A6*Bfz<%cC+cy@)ja`Z4! zIdmZ2eXbE3)~@pAHGlkY*Zqi@Ni%V9F5K|^DygG#=m0SeI$wNtx#vS6V@+l3`8(0l z@@1?Y|Cppp>Gj7ZZ6}#O%By8~r>zsME!ZK@zlu~bXa6ic;?H{XUILzCuM>$%2KVRwVC+rR}7P+Hv9#wJ z))t^s8*&2>fnpK}rM=Cb(vh|PD{L#MTji@s^Y|&7x2KqTJ1gt=T$AVRoO5;|ldeXe z{m+EHr1HS%m*l0)+m3U#sY{l1tRodk1SL=^mCnR|!Fc$&9Qy0w<8P58AGz0WNn;&( z$Q#raXEcr$l=}bh#C;NM(WY+1RgvnBze&HOc!lVpM^l@w5~8NtTlu5sB1a~o9k=89 zZ+SWCZKUN<$F-R!s|7tmxBv!|$9y#DA+b)XLY)4i`))i3cy08D$UY3{q*=J;#J=FWW2MM18nc(iXWpj-2o4HDT6WF-PZ$TQ*9?6l*#64=!~Cn#Cqll z54SlEqZ~W-X+LuI``B+>=Cws@BV0{1~&f9^8o9_C2f zQ1&4D#yIw!s8LW1yMb~nbJxU7CiB;rGk=5}_>W}V=DNTvNARmQg0A+Abq94PBQPCF z=8CUTvV}t3-(fzu7i+~po|}2XcOC2SUcvXhov*H!7t7Y+*X4UqCqBPV-Lw&pp-gIm zn`$4%UIY8Vd~tK0>-znBUX^U^w^nqj#Ga&-=z>|byg5#n`b;%j~^{~))IL~bl>e+_dWPgf6#kZiyNyxq^%rm zYLHz*^%zvadxTS^QsY_0ytOg8-PJh5y(qrR6XTeXL&WTvw~7^ySBn+P7K^)4CXIFd zdVgE&#yMX^pE-6RKFFnwwB1=;yQ(X;)|~@A&qr*<#J?qdK>YOK`C{>cdqi!`PJds9 z=R?S?!=P0!m?xH2S7D#^KoozBPOQV9Cx)(Y#?{x>)_wuFt8uw|K+&-y>=*ac%>Bo3 zJ?oCO`umgN`j}_ht|k_Z(aa4^3-<-S%fR=iu?;Lj;2KXS8OBw84fxoUid!5fsN(Xpi+pZj0GiU8O zVoO5K=BTNu>BjFEVAsoeH8+{If_T_Bx!-HozAn;-_R4+V*zp~F+!H2`1zyUK&z$>` zV&?MjY<4a&MtlHx;_q;pzK*?;wj@1aI`_kPHuR(EQ^ceRW5k4UBgNDkzAx6SSQ^-y z=H3`-tefzik>DLG?zr_P@#Lcqx%+RS_;`KFq_Zv3ZEf;QIpTpN;LP?>V2oS48{?fV znz6tCP}NVw$l-(Jn6Kt5e=l*^<^ANDhxT{ygrCyLj9xmjE@X}o)8;$xORxbrOc^&h-){AyuN|?M-drbEFJCG~4Zo~YEE?C` zyKWb4INxCI%$_+ty7%pVv!eNVA2s4Kv6{KpRA;oe*@xXSuoAbzUy*tnYYntf;@KqU zrJp_iuoyg`Pg&R*&xQDwM$K3CqUxSIUHnPypS@QM_7EO-f1mr=&#rvjiy_->VBd?BypdyO?9DQL*r@lny&MSi<=?dD zt)V*pn5(7(|9jd`qrZqP_!eu(Abe+(aQ5KSeP6~@4v}jht?$sj9b)tqgUit2x5F@; z#czFit&D+*$JO~BF*AoYZQArX>~SvyJu>dl?)WhyK9*hjvvB_1#`;b5d-YwGkI(lz znfBc)M~Yf}r={ay-0@9`y^z?|zO4O}Q77jVo_+CKfY|lC*%n?tC>&fTb8hnWsdnd@ zGr0}?wln>r?FIM-i({CtPxWEoe~Q>oo~-=>)(cOE>-jFFzHLj6k3Rp@Huz+m~M&|)7A9o*p#pU9FJYx#vKi?0YCU$=32>r{o4Sio!HTPrX4c|)^VNQ!Z z6L$L`>p1U`Hx6qYjbqu8hwk%YV)TXO#Lo2u_SBbX+`Mz#8$IIkvTd-PZ<}_#Xbt3z zF>V`k5Wfex4d>3myo}Eu?a$az!y@k$_-=HGyB4Y_=Iz<=%X_yyvVW&LzXo$Ba{h{b z(EpMP<^0rCzu$|I8H>0wzfZgqXSU&Xh|^CW*ai)OcV_!TV%Ad_)^(F%$2;TNy>$a( zAB{;^Ki4nZQ{cO!#A(Di4!22bPVdJz6nBdLeZsjEwM!rN?l8Y$vQH5+$4~xB8LJ=W zDerwHeP~Z4Ho7f&XWLf}8z@#jxuny1QsE8uJJScwvCHv(&okLzJ_XyI?PML^Cu1b@ zA6SoNt$up_Q);X^3woQSZp4s0b7swm)me#eYPlY(+5L)_FGip8Xg7Zb5xGj8;7`MvJSCzfD7+3BBk z$DhH5ZI$)RbsqOERz9&#Q7PQv-Y;gUWDsRN5&e2n5b{f zV(^>3e44W9uUS?)liLn+?z<=2tU)^Vy8r5D?)_q2_agL!)^bsuUE2L5xglc~wdKa@ zG-A6RYaX>f9R}|@fp5Kspf7u4Y3Fec*(wur)c2$uQ~D);HvR8>Ptmas-?ckD2Z5h+ zJI9@5FM(c;Ur9VJ9es122<@F{%z+nSICff8oq=5Ap0l^62>Vlh3=@1Rzkhv*cy2|^Y6Qk+Bnxbfjx$&R+R+OKNBv9M!^9HUOQ z+h2oxeJqZUy*Qq7+Q{RH_p81=Ykw_iyo`^$rot)w&%Fs?+k-1`=B4nO{TJnU3}v)k zNLHP%?V)X=i3zVH#?m!>L$IyQ`U=L?McAKE-@KHG(~E6vTbaHaeI#rDp0?U7>nkRd zvdalh@m;Op9370YwK0$@<@gffN%kw + + + + + + + Account + + + + +
+
+

WebAuthN Demo

+
+
+

Your Account:

+
    +
  • + Email: + +
  • +
+
+
+

Existing Passkeys

+
    +
  • + +
    + + +
    +
  • +
+
+
+

Register New Passkey

+
+ + +
+
+
+

Logout

+ + Logout + +
+
+ + + + \ No newline at end of file diff --git a/sep490-idp/src/main/resources/templates/login.html b/sep490-idp/src/main/resources/templates/login.html index 9002f22e..438c50c4 100644 --- a/sep490-idp/src/main/resources/templates/login.html +++ b/sep490-idp/src/main/resources/templates/login.html @@ -3,6 +3,8 @@ + + @@ -39,7 +41,7 @@

- +
@@ -60,5 +62,34 @@

+ From f397bb484cbaa6dc2556ce15176aa40240a982ee Mon Sep 17 00:00:00 2001 From: Tran Gia Bao Date: Wed, 1 Jan 2025 14:09:28 +0700 Subject: [PATCH 3/3] feature: Forgot password with mail OTP --- .../sep490/common/api/utils/SEPUtils.java | 25 ++++ sep490-idp/build.gradle | 2 + .../java/sep490/idp/configs/MailConfig.java | 26 +++++ .../sep490/idp/configs/SecurityConfig.java | 23 ++-- .../controller/ForgotPasswordController.java | 109 ++++++++++++++++++ .../sep490/idp/dto/ForgotPasswordDTO.java | 4 + .../idp/dto/ForgotResetPasswordDTO.java | 17 +++ .../src/main/java/sep490/idp/dto/OtpDTO.java | 10 ++ .../main/java/sep490/idp/entity/UserOTP.java | 38 ++++++ .../idp/repository/UserOTPRepository.java | 15 +++ .../idp/service/ForgotPasswordService.java | 11 ++ .../impl/ForgotPasswordServiceImpl.java | 87 ++++++++++++++ .../main/java/sep490/idp/utils/EmailUtil.java | 108 +++++++++++++++++ .../java/sep490/idp/utils/IEmailUtil.java | 6 + .../java/sep490/idp/utils/IMessageUtil.java | 6 + .../java/sep490/idp/utils/MessageUtil.java | 24 ++++ .../java/sep490/idp/utils/SEPMailMessage.java | 21 ++++ sep490-idp/src/main/resources/application.yml | 8 ++ .../db/migration/V0.0.1.2__UserOTP.sql | 15 +++ .../main/resources/i18n/messages.properties | 20 +++- .../resources/i18n/messages_vi.properties | 20 ++++ .../mailTemplates/forgot-password-otp_en.ftl | 26 +++++ .../mailTemplates/forgot-password-otp_vi.ftl | 26 +++++ .../main/resources/templates/enter-otp.html | 54 +++++++++ .../resources/templates/forgot-password.html | 49 ++++++++ .../templates/forgot-reset-password.html | 57 +++++++++ .../src/main/resources/templates/login.html | 3 + 27 files changed, 798 insertions(+), 12 deletions(-) create mode 100644 sep490-common/src/main/java/sep490/common/api/utils/SEPUtils.java create mode 100644 sep490-idp/src/main/java/sep490/idp/configs/MailConfig.java create mode 100644 sep490-idp/src/main/java/sep490/idp/controller/ForgotPasswordController.java create mode 100644 sep490-idp/src/main/java/sep490/idp/dto/ForgotPasswordDTO.java create mode 100644 sep490-idp/src/main/java/sep490/idp/dto/ForgotResetPasswordDTO.java create mode 100644 sep490-idp/src/main/java/sep490/idp/dto/OtpDTO.java create mode 100644 sep490-idp/src/main/java/sep490/idp/entity/UserOTP.java create mode 100644 sep490-idp/src/main/java/sep490/idp/repository/UserOTPRepository.java create mode 100644 sep490-idp/src/main/java/sep490/idp/service/ForgotPasswordService.java create mode 100644 sep490-idp/src/main/java/sep490/idp/service/impl/ForgotPasswordServiceImpl.java create mode 100644 sep490-idp/src/main/java/sep490/idp/utils/EmailUtil.java create mode 100644 sep490-idp/src/main/java/sep490/idp/utils/IEmailUtil.java create mode 100644 sep490-idp/src/main/java/sep490/idp/utils/IMessageUtil.java create mode 100644 sep490-idp/src/main/java/sep490/idp/utils/MessageUtil.java create mode 100644 sep490-idp/src/main/java/sep490/idp/utils/SEPMailMessage.java create mode 100644 sep490-idp/src/main/resources/db/migration/V0.0.1.2__UserOTP.sql create mode 100644 sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_en.ftl create mode 100644 sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_vi.ftl create mode 100644 sep490-idp/src/main/resources/templates/enter-otp.html create mode 100644 sep490-idp/src/main/resources/templates/forgot-password.html create mode 100644 sep490-idp/src/main/resources/templates/forgot-reset-password.html diff --git a/sep490-common/src/main/java/sep490/common/api/utils/SEPUtils.java b/sep490-common/src/main/java/sep490/common/api/utils/SEPUtils.java new file mode 100644 index 00000000..66067d61 --- /dev/null +++ b/sep490-common/src/main/java/sep490/common/api/utils/SEPUtils.java @@ -0,0 +1,25 @@ +package sep490.common.api.utils; + +import java.security.SecureRandom; + +public class SEPUtils { + + private SEPUtils() { + // Utility class, no instances allowed + } + + private static final SecureRandom random = new SecureRandom(); + + public static String generateRandomOTP(int size) { + if (size < 1) { + throw new IllegalArgumentException("Size must be at least 1"); + } + StringBuilder otp = new StringBuilder(size); + for (int i = 0; i < size; i++) { + int digit = random.nextInt(10); + otp.append(digit); + } + return otp.toString(); + } + +} diff --git a/sep490-idp/build.gradle b/sep490-idp/build.gradle index c467b5cc..58ab54f0 100644 --- a/sep490-idp/build.gradle +++ b/sep490-idp/build.gradle @@ -70,6 +70,8 @@ dependencies { annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0" annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.2' implementation 'org.mapstruct:mapstruct:1.6.2' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-freemarker' } tasks.register('npmBuild', NpmTask) { diff --git a/sep490-idp/src/main/java/sep490/idp/configs/MailConfig.java b/sep490-idp/src/main/java/sep490/idp/configs/MailConfig.java new file mode 100644 index 00000000..afe6c187 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/configs/MailConfig.java @@ -0,0 +1,26 @@ +package sep490.idp.configs; + +import freemarker.cache.ClassTemplateLoader; +import freemarker.cache.TemplateLoader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer; + +import static freemarker.template.Configuration.VERSION_2_3_33; + + +@Configuration +public class MailConfig { + + @Bean + FreeMarkerConfigurer freeMarkerConfigurer() { + freemarker.template.Configuration configuration = new freemarker.template.Configuration(VERSION_2_3_33); + TemplateLoader templateLoader = new ClassTemplateLoader(this.getClass(), "/mailTemplates"); + configuration.setTemplateLoader(templateLoader); + configuration.setDefaultEncoding("UTF-8"); + configuration.setLocalizedLookup(true); + FreeMarkerConfigurer freeMarkerConfigurer = new FreeMarkerConfigurer(); + freeMarkerConfigurer.setConfiguration(configuration); + return freeMarkerConfigurer; + } +} diff --git a/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java b/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java index 0f8bc963..3341726c 100644 --- a/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java +++ b/sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java @@ -82,22 +82,23 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws http.cors(cors -> cors.configurationSource(corsConfigurationSource())); http.authorizeHttpRequests( - c -> c - .requestMatchers("/css/**", "/img/**", "/js/**", "/favicon.ico").permitAll() - .requestMatchers(antMatcher("/signup"), antMatcher("/login")).permitAll() - .requestMatchers(antMatcher("/passkey/login")).permitAll() - .anyRequest().authenticated()); + c -> c + .requestMatchers("/css/**", "/img/**", "/js/**", "/favicon.ico").permitAll() + .requestMatchers(antMatcher("/signup"), antMatcher("/login")).permitAll() + .requestMatchers(antMatcher("/passkey/login")).permitAll() + .requestMatchers(antMatcher("/forgot-password"), antMatcher("/enter-otp"), antMatcher("/forgot-reset-password")).permitAll() + .anyRequest().authenticated()); http.formLogin(form -> form.loginPage("/login") - .usernameParameter("email") - .passwordParameter("password") - .defaultSuccessUrl("/success", false) - .failureHandler(authenticationFailureHandler()) - .permitAll()); + .usernameParameter("email") + .passwordParameter("password") + .defaultSuccessUrl("/success", false) + .failureHandler(authenticationFailureHandler()) + .permitAll()); // Passkey Configuration http.webAuthn(passkeys -> - passkeys.rpName("SEP490 IDP").rpId("localhost").allowedOrigins("http://localhost:8080")); + passkeys.rpName("SEP490 IDP").rpId("localhost").allowedOrigins("http://localhost:8080")); return http.build(); } diff --git a/sep490-idp/src/main/java/sep490/idp/controller/ForgotPasswordController.java b/sep490-idp/src/main/java/sep490/idp/controller/ForgotPasswordController.java new file mode 100644 index 00000000..c6e7a44c --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/controller/ForgotPasswordController.java @@ -0,0 +1,109 @@ +package sep490.idp.controller; + +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import sep490.idp.dto.ForgotPasswordDTO; +import sep490.idp.dto.ForgotResetPasswordDTO; +import sep490.idp.dto.OtpDTO; +import sep490.idp.service.ForgotPasswordService; +import sep490.idp.utils.IMessageUtil; + + +@Controller +@RequiredArgsConstructor +public class ForgotPasswordController { + + private final ForgotPasswordService forgotPasswordService; + private final IMessageUtil messageUtil; + private final HttpSession session; + + private static final String SESSION_OTP_SENT = "otp_sent"; + private static final String SESSION_OTP_VERIFIED = "otp_verified"; + private static final String SESSION_FORGOT_PASSWORD_EMAIL = "forgot_password_email"; + private static final String ERROR_MESSAGE = "errorMessage"; + private static final String MESSAGE = "message"; + + @GetMapping("/forgot-password") + public String forgotPasswordPage(Model model) { + session.setAttribute(SESSION_OTP_SENT, false); + session.setAttribute(SESSION_OTP_VERIFIED, false); + model.addAttribute("forgotPasswordDTO", new ForgotPasswordDTO(null)); + return "forgot-password"; + } + + @PostMapping("/forgot-password") + public String processForgotPassword(@ModelAttribute ForgotPasswordDTO forgotPasswordDTO, + RedirectAttributes redirectAttributes) { + String email = forgotPasswordDTO.email(); + if (forgotPasswordService.sendResetPasswordEmail(email)) { + session.setAttribute(SESSION_FORGOT_PASSWORD_EMAIL, email); + session.setAttribute(SESSION_OTP_SENT, true); + return "redirect:/enter-otp"; + } else { + redirectAttributes.addFlashAttribute(ERROR_MESSAGE, messageUtil.getMessage("forgotPassword.error.noUser")); + return "redirect:/forgot-password"; + } + } + + @GetMapping("/enter-otp") + public String showOtpPage(Model model) { + Boolean otpSent = (Boolean) session.getAttribute(SESSION_OTP_SENT); + if (otpSent == null || !otpSent) { + return "redirect:/login"; + } + model.addAttribute("otpDTO", new OtpDTO()); + return "enter-otp"; + } + + @PostMapping("/enter-otp") + public String verifyOtp(@ModelAttribute OtpDTO otpDTO, Model model) { + String email = (String) session.getAttribute(SESSION_FORGOT_PASSWORD_EMAIL); + boolean isValid = forgotPasswordService.verifyOtp(otpDTO.getOtpCode(), email); + + if (isValid) { + session.setAttribute(SESSION_OTP_VERIFIED, true); + return "redirect:/forgot-reset-password"; + } + model.addAttribute(ERROR_MESSAGE, messageUtil.getMessage("forgotPassword.error.invalidOtp")); + return "enter-otp"; + } + + @GetMapping("/forgot-reset-password") + public String showResetPasswordPage(Model model) { + Boolean otpVerified = (Boolean) session.getAttribute(SESSION_OTP_VERIFIED); + if (otpVerified == null || !otpVerified) { + return "redirect:/login"; + } + + model.addAttribute("resetPasswordDTO", new ForgotResetPasswordDTO()); + return "forgot-reset-password"; + } + + @PostMapping("/forgot-reset-password") + public String resetPassword(@Valid @ModelAttribute("resetPasswordDTO") ForgotResetPasswordDTO resetPasswordDTO, + BindingResult result, Model model, RedirectAttributes redirectAttributes) { + + if (result.hasErrors()) { + return "forgot-reset-password"; + } + + if (!forgotPasswordService.changePassword(resetPasswordDTO, (String) session.getAttribute(SESSION_FORGOT_PASSWORD_EMAIL))) { + model.addAttribute(ERROR_MESSAGE, messageUtil.getMessage("validation.password.invalid.notmatch")); + return "enter-otp"; + } + + redirectAttributes.addFlashAttribute(MESSAGE, messageUtil.getMessage("forgotPassword.notification")); + return "redirect:/login"; + } +} + + + diff --git a/sep490-idp/src/main/java/sep490/idp/dto/ForgotPasswordDTO.java b/sep490-idp/src/main/java/sep490/idp/dto/ForgotPasswordDTO.java new file mode 100644 index 00000000..5b0d917f --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/dto/ForgotPasswordDTO.java @@ -0,0 +1,4 @@ +package sep490.idp.dto; + +public record ForgotPasswordDTO(String email) { +} diff --git a/sep490-idp/src/main/java/sep490/idp/dto/ForgotResetPasswordDTO.java b/sep490-idp/src/main/java/sep490/idp/dto/ForgotResetPasswordDTO.java new file mode 100644 index 00000000..fd21571d --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/dto/ForgotResetPasswordDTO.java @@ -0,0 +1,17 @@ +package sep490.idp.dto; + +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +import static sep490.common.api.utils.CommonConstant.PASSWORD_PATTERN; + +@Getter +@Setter +public class ForgotResetPasswordDTO { + @Pattern(regexp = PASSWORD_PATTERN, message = "{validation.password.invalid}") + private String password; + + @Pattern(regexp = PASSWORD_PATTERN, message = "{validation.confirmPassword.invalid}") + private String confirmPassword; +} diff --git a/sep490-idp/src/main/java/sep490/idp/dto/OtpDTO.java b/sep490-idp/src/main/java/sep490/idp/dto/OtpDTO.java new file mode 100644 index 00000000..53a79383 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/dto/OtpDTO.java @@ -0,0 +1,10 @@ +package sep490.idp.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class OtpDTO { + private String otpCode; +} diff --git a/sep490-idp/src/main/java/sep490/idp/entity/UserOTP.java b/sep490-idp/src/main/java/sep490/idp/entity/UserOTP.java new file mode 100644 index 00000000..e21526ea --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/entity/UserOTP.java @@ -0,0 +1,38 @@ +package sep490.idp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sep490.common.api.utils.SEPUtils; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_otp") +@Getter +@NoArgsConstructor +public class UserOTP extends AbstractBaseEntity { + + @Column(name = "otp_code", nullable = false) + private String otpCode; + + @Column(name = "expired_time", nullable = false) + private LocalDateTime expiredTime; + + @OneToOne + @JoinColumn(name = "user_id") + private UserEntity user; + + + + public void updateOtp(UserEntity user) { + this.user = user; + this.otpCode = SEPUtils.generateRandomOTP(6); + this.expiredTime = LocalDateTime.now().plusMinutes(10); + } + +} diff --git a/sep490-idp/src/main/java/sep490/idp/repository/UserOTPRepository.java b/sep490-idp/src/main/java/sep490/idp/repository/UserOTPRepository.java new file mode 100644 index 00000000..fe5afbd0 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/repository/UserOTPRepository.java @@ -0,0 +1,15 @@ +package sep490.idp.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import sep490.idp.entity.UserOTP; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserOTPRepository extends JpaRepository { + + Optional findByUserEmail(String userEmail); + +} diff --git a/sep490-idp/src/main/java/sep490/idp/service/ForgotPasswordService.java b/sep490-idp/src/main/java/sep490/idp/service/ForgotPasswordService.java new file mode 100644 index 00000000..2eff8d69 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/service/ForgotPasswordService.java @@ -0,0 +1,11 @@ +package sep490.idp.service; + +import sep490.idp.dto.ForgotResetPasswordDTO; + +public interface ForgotPasswordService { + boolean sendResetPasswordEmail(String email); + + boolean verifyOtp(String otpCode, String email); + + boolean changePassword(ForgotResetPasswordDTO resetPasswordDTO, String forgotPasswordEmail); +} diff --git a/sep490-idp/src/main/java/sep490/idp/service/impl/ForgotPasswordServiceImpl.java b/sep490-idp/src/main/java/sep490/idp/service/impl/ForgotPasswordServiceImpl.java new file mode 100644 index 00000000..2bf45fb0 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/service/impl/ForgotPasswordServiceImpl.java @@ -0,0 +1,87 @@ +package sep490.idp.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sep490.common.api.exceptions.TechnicalException; +import sep490.idp.dto.ForgotResetPasswordDTO; +import sep490.idp.entity.UserEntity; +import sep490.idp.entity.UserOTP; +import sep490.idp.repository.UserOTPRepository; +import sep490.idp.repository.UserRepository; +import sep490.idp.service.ForgotPasswordService; +import sep490.idp.utils.IEmailUtil; +import sep490.idp.utils.IMessageUtil; +import sep490.idp.utils.SEPMailMessage; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class ForgotPasswordServiceImpl implements ForgotPasswordService { + + private final UserRepository userRepo; + private final UserOTPRepository otpRepo; + private final IEmailUtil emailUtil; + private final IMessageUtil messageUtil; + private final PasswordEncoder passwordEncoder; + + @Override + public boolean sendResetPasswordEmail(String email) { + UserEntity user = userRepo.findByEmail(email).orElse(null); + if (user == null) { + return false; + } + UserOTP userOTP = otpRepo.findByUserEmail(email).orElse(new UserOTP()); + userOTP.updateOtp(user); + otpRepo.save(userOTP); + SEPMailMessage message = populateOTPMailMessage(email, userOTP); + emailUtil.sendMail(message); + return true; + } + + @Override + public boolean verifyOtp(String otpCode, String email) { + UserOTP userOTP = otpRepo.findByUserEmail(email).orElse(null); + return userOTP != null + && userOTP.getOtpCode().equals(otpCode) + && LocalDateTime.now().isBefore(userOTP.getExpiredTime()); + } + + @Override + @Transactional + public boolean changePassword(ForgotResetPasswordDTO resetPasswordDTO, String email) { + if (!resetPasswordDTO.getPassword().equals(resetPasswordDTO.getConfirmPassword()) || StringUtils.isEmpty(email)) { + return false; + } + UserOTP userOTP = otpRepo.findByUserEmail(email).orElseThrow(() -> new TechnicalException("OTP not exists")); + UserEntity user = userOTP.getUser(); + + user.setPassword(passwordEncoder.encode(resetPasswordDTO.getPassword())); + otpRepo.delete(userOTP); + + return true; + } + + private SEPMailMessage populateOTPMailMessage(String email, UserOTP userOTP) { + SEPMailMessage message = new SEPMailMessage(); + + message.setTemplateName("forgot-password-otp.ftl"); + message.setTo(email); + message.setSubject(messageUtil.getMessage("resetPassword.title")); + + Map map = new HashMap<>(); + map.put("userEmail", email); + map.put("otpCode", userOTP.getOtpCode()); + message.setTemplateModels(map); + + return message; + } +} diff --git a/sep490-idp/src/main/java/sep490/idp/utils/EmailUtil.java b/sep490-idp/src/main/java/sep490/idp/utils/EmailUtil.java new file mode 100644 index 00000000..55ca666d --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/utils/EmailUtil.java @@ -0,0 +1,108 @@ +package sep490.idp.utils; + +import freemarker.template.Template; +import freemarker.template.TemplateException; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.io.FileSystemResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.springframework.ui.freemarker.FreeMarkerTemplateUtils; +import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer; +import sep490.common.api.exceptions.TechnicalException; + +import java.io.File; +import java.io.IOException; +import java.util.Date; +import java.util.Map.Entry; + +@Component +@RequiredArgsConstructor +public class EmailUtil implements IEmailUtil { + + private final JavaMailSender mailSender; + private final FreeMarkerConfigurer freeMarkerConfigurer; + + private static final String EMAIL_HOST_FROM = "SEP490"; + + @Override + public void sendMail(SEPMailMessage mailMessage) { + try { + String[] to = mailMessage.getTo(); + if (to == null || to.length == 0) { + throw new TechnicalException("Cannot send mail because to field is empty"); + } + MimeMessageHelper helper = new MimeMessageHelper(mailSender.createMimeMessage(), true, "UTF-8"); + + copyDataToHelper(mailMessage, helper); + + mailSender.send(helper.getMimeMessage()); + } catch (Exception e) { + throw new TechnicalException(e); + } + } + + private void copyContentToHelper(SEPMailMessage mailMessage, MimeMessageHelper helper) throws IOException, MessagingException, TemplateException { + if (StringUtils.isNotEmpty(mailMessage.getTemplateName())) { + Template template = freeMarkerConfigurer.getConfiguration().getTemplate(mailMessage.getTemplateName(), LocaleContextHolder.getLocale()); + helper.setText(FreeMarkerTemplateUtils.processTemplateIntoString(template, mailMessage.getTemplateModels()), true); + } else { + String text = mailMessage.getText(); + if (StringUtils.isNotEmpty(text)) { + helper.setText(text); + } else { + throw new TechnicalException("Trying to send empty email"); + } + } + } + + private void copyDataToHelper(SEPMailMessage message, MimeMessageHelper helper) throws MessagingException, TemplateException, IOException { + copyContentToHelper(message, helper); + copySimpleDataToHelper(message, helper); + + if (message.getAttachments() != null && !message.getAttachments().isEmpty()) { + for (Entry entry : message.getAttachments().entrySet()) { + helper.addAttachment(entry.getKey(), new FileSystemResource(entry.getValue())); + } + } + } + + + private void copySimpleDataToHelper(SEPMailMessage message, MimeMessageHelper helper) throws MessagingException { + helper.setFrom(EMAIL_HOST_FROM); + + Date sentDate = message.getSentDate(); + if (sentDate != null) { + helper.setSentDate(sentDate); + } + + String subject = message.getSubject(); + if (subject != null) { + helper.setSubject(subject); + } + + String[] messageTo = message.getTo(); + if (messageTo != null) { + helper.setTo(messageTo); + } + + String[] bcc = message.getBcc(); + if (bcc != null) { + helper.setBcc(bcc); + } + + String[] cc = message.getCc(); + if (cc != null) { + helper.setCc(cc); + } + + String replyTo = message.getReplyTo(); + if (replyTo != null) { + helper.setReplyTo(replyTo); + } + } + +} diff --git a/sep490-idp/src/main/java/sep490/idp/utils/IEmailUtil.java b/sep490-idp/src/main/java/sep490/idp/utils/IEmailUtil.java new file mode 100644 index 00000000..641153e0 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/utils/IEmailUtil.java @@ -0,0 +1,6 @@ +package sep490.idp.utils; + +public interface IEmailUtil { + + void sendMail(SEPMailMessage mailMessage); +} diff --git a/sep490-idp/src/main/java/sep490/idp/utils/IMessageUtil.java b/sep490-idp/src/main/java/sep490/idp/utils/IMessageUtil.java new file mode 100644 index 00000000..79ab2422 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/utils/IMessageUtil.java @@ -0,0 +1,6 @@ +package sep490.idp.utils; + +public interface IMessageUtil { + String getMessage(String code, Object... args); + String getMessage(String code); +} diff --git a/sep490-idp/src/main/java/sep490/idp/utils/MessageUtil.java b/sep490-idp/src/main/java/sep490/idp/utils/MessageUtil.java new file mode 100644 index 00000000..d0c00a36 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/utils/MessageUtil.java @@ -0,0 +1,24 @@ +package sep490.idp.utils; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MessageUtil implements IMessageUtil{ + + private final MessageSource messageSource; + + @Override + public String getMessage(String code, Object... args) { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); + } + + @Override + public String getMessage(String code) { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } + +} diff --git a/sep490-idp/src/main/java/sep490/idp/utils/SEPMailMessage.java b/sep490-idp/src/main/java/sep490/idp/utils/SEPMailMessage.java new file mode 100644 index 00000000..14ae5856 --- /dev/null +++ b/sep490-idp/src/main/java/sep490/idp/utils/SEPMailMessage.java @@ -0,0 +1,21 @@ +package sep490.idp.utils; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.mail.SimpleMailMessage; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +@Getter +@Setter +public class SEPMailMessage extends SimpleMailMessage { + + private String templateName; + + private Map templateModels = new HashMap<>(); + + private Map attachments = new HashMap<>(); + +} diff --git a/sep490-idp/src/main/resources/application.yml b/sep490-idp/src/main/resources/application.yml index 80e8eb3b..a901bfd1 100644 --- a/sep490-idp/src/main/resources/application.yml +++ b/sep490-idp/src/main/resources/application.yml @@ -18,6 +18,14 @@ spring: - phone require-authorization-consent: false require-proof-key: true + mail: + host: ${SMTP_HOST:127.0.0.1} + port: ${SMTP_PORT:1025} + username: + password: + properties: + mail.smtp.starttls.enable: true + mail.smtp.auth: false application: name: sep490-idp datasource: diff --git a/sep490-idp/src/main/resources/db/migration/V0.0.1.2__UserOTP.sql b/sep490-idp/src/main/resources/db/migration/V0.0.1.2__UserOTP.sql new file mode 100644 index 00000000..196e417d --- /dev/null +++ b/sep490-idp/src/main/resources/db/migration/V0.0.1.2__UserOTP.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS user_otp +( + id UUID NOT NULL DEFAULT gen_random_uuid(), + version INTEGER NOT NULL, + user_id UUID NOT NULL, + otp_code VARCHAR(255) NOT NULL, + expired_time TIMESTAMP NOT NULL +); +ALTER TABLE user_otp DROP CONSTRAINT IF EXISTS pk_user_otp; +ALTER TABLE user_otp DROP CONSTRAINT IF EXISTS fk_user_otp_user; + +ALTER TABLE user_otp + ADD CONSTRAINT pk_user_otp PRIMARY KEY (id), + ADD CONSTRAINT fk_user_otp_user FOREIGN KEY (user_id) REFERENCES users (id); +-- Do not add constraints on otp code length at database side diff --git a/sep490-idp/src/main/resources/i18n/messages.properties b/sep490-idp/src/main/resources/i18n/messages.properties index d152e3db..3b740d3e 100644 --- a/sep490-idp/src/main/resources/i18n/messages.properties +++ b/sep490-idp/src/main/resources/i18n/messages.properties @@ -32,7 +32,25 @@ validation.lastName.invalid=Last name is invalid validation.password.invalid.notmatch=Passwords do not match validation.email.invalid.exist=Email is already registered - +#Forgot Password +forgotPassword.title=Forgot Password +forgotPassword.label.email=Email Address +forgotPassword.btn.submit=Submit +forgotPassword.btn.backToLogin=Back to Login +forgotPassword.error.noUser=No user found with that email address. +forgotPassword.error.invalidOtp=Invalid OTP. Please try again. +forgotPassword.notification=Password updated successfully + +enterOtp.title=Enter OTP +enterOtp.label.otp=OTP Code +enterOtp.btn.submit=Submit +enterOtp.btn.resendOtp=Resend OTP + +resetPassword.title=Reset Password +resetPassword.label.password=New Password +resetPassword.label.confirmPassword=Confirm Password +resetPassword.btn.submit=Reset Password +resetPassword.btn.backToForgotPassword=Back to Forgot Password # Language language.english=English diff --git a/sep490-idp/src/main/resources/i18n/messages_vi.properties b/sep490-idp/src/main/resources/i18n/messages_vi.properties index ebea8bd1..943942e0 100644 --- a/sep490-idp/src/main/resources/i18n/messages_vi.properties +++ b/sep490-idp/src/main/resources/i18n/messages_vi.properties @@ -21,6 +21,7 @@ signup.label.phone=Số điện thoại signup.btn.signup=Đăng Ký signup.btn.login=Đăng Nhập +# Validation validation.email.invalid=Email không hợp lệ validation.email.length=Email không vượt quá 255 kí tự validation.phone.invalid=Định dạng số điện thoại không hợp lệ @@ -31,6 +32,25 @@ validation.lastName.invalid=Họ không hợp lệ validation.password.invalid.notmatch=Mật khẩu không khớp validation.email.invalid.exist=Email đã tồn tại +# Forgot Password +forgotPassword.title=Quên Mật Khẩu +forgotPassword.label.email=Địa Chỉ Email +forgotPassword.btn.submit=Gửi +forgotPassword.btn.backToLogin=Quay Lại Đăng Nhập +forgotPassword.error.noUser=Email không tồn tại +forgotPassword.error.invalidOtp=Mã xác thực không chính xác, vui lòng thử lại +forgotPassword.notification=Đổi mật khẩu thành công + +enterOtp.title=Nhập OTP +enterOtp.label.otp=Mã OTP +enterOtp.btn.submit=Gửi +enterOtp.btn.resendOtp=Gửi lại OTP + +resetPassword.title=Đặt Lại Mật Khẩu +resetPassword.label.password=Mật Khẩu Mới +resetPassword.label.confirmPassword=Xác Nhận Mật Khẩu +resetPassword.btn.submit=Đặt Lại Mật Khẩu +resetPassword.btn.backToForgotPassword=Quay Lại Trang Quên Mật Khẩu # LANGUAGE language.english=Tiếng Anh diff --git a/sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_en.ftl b/sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_en.ftl new file mode 100644 index 00000000..9199bc51 --- /dev/null +++ b/sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_en.ftl @@ -0,0 +1,26 @@ + + + + + + Reset Password + + +
+

Reset Your Password

+ +

Dear ${userEmail},

+ +

We received a request to reset your password. Please use the following OTP code to proceed:

+ +

${otpCode}

+ +

This code is valid for 10 minutes. If you did not request a password reset, you can safely ignore this email.

+ +

Thank you,

+ +

Regards,

+

The Support Team

+
+ + diff --git a/sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_vi.ftl b/sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_vi.ftl new file mode 100644 index 00000000..0706ff63 --- /dev/null +++ b/sep490-idp/src/main/resources/mailTemplates/forgot-password-otp_vi.ftl @@ -0,0 +1,26 @@ + + + + + + Đặt Lại Mật Khẩu + + +
+

Đặt Lại Mật Khẩu

+ +

Xin chào ${userEmail},

+ +

Chúng tôi đã nhận được yêu cầu đặt lại mật khẩu của bạn. Vui lòng sử dụng mã OTP sau để tiếp tục:

+ +

${otpCode}

+ +

Mã này có hiệu lực trong 10 phút. Nếu bạn không yêu cầu đặt lại mật khẩu, bạn có thể bỏ qua email này một cách an toàn.

+ +

Cảm ơn bạn,

+ +

Trân trọng,

+

Đội Hỗ Trợ

+
+ + diff --git a/sep490-idp/src/main/resources/templates/enter-otp.html b/sep490-idp/src/main/resources/templates/enter-otp.html new file mode 100644 index 00000000..cecf331a --- /dev/null +++ b/sep490-idp/src/main/resources/templates/enter-otp.html @@ -0,0 +1,54 @@ + + + + + + Enter OTP + + + +
+
+ +
+

Enter OTP

+
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+ +
+ + + +
+
+ + diff --git a/sep490-idp/src/main/resources/templates/forgot-password.html b/sep490-idp/src/main/resources/templates/forgot-password.html new file mode 100644 index 00000000..e02f7347 --- /dev/null +++ b/sep490-idp/src/main/resources/templates/forgot-password.html @@ -0,0 +1,49 @@ + + + + + + Forgot Password + + + +
+
+ +
+

Forgot Password

+
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+ +
+ + + +
+
+ + diff --git a/sep490-idp/src/main/resources/templates/forgot-reset-password.html b/sep490-idp/src/main/resources/templates/forgot-reset-password.html new file mode 100644 index 00000000..870e2e26 --- /dev/null +++ b/sep490-idp/src/main/resources/templates/forgot-reset-password.html @@ -0,0 +1,57 @@ + + + + + + Reset Password + + + +
+
+ +
+

Reset Password

+
+
+ + +

+
+
+ + +

+
+
+ +
+
+ +
+
+
+ + +
+ +
+ + + +
+
+ + diff --git a/sep490-idp/src/main/resources/templates/login.html b/sep490-idp/src/main/resources/templates/login.html index 438c50c4..feb687e0 100644 --- a/sep490-idp/src/main/resources/templates/login.html +++ b/sep490-idp/src/main/resources/templates/login.html @@ -30,6 +30,9 @@

+
+ +