Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/idp implement #42

Merged
merged 3 commits into from
Jan 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions sep490-common/src/main/java/sep490/common/api/utils/SEPUtils.java
Original file line number Diff line number Diff line change
@@ -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();
}

}
5 changes: 4 additions & 1 deletion sep490-idp/build.gradle
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down Expand Up @@ -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'
Expand All @@ -69,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) {
Expand Down
26 changes: 26 additions & 0 deletions sep490-idp/src/main/java/sep490/idp/configs/MailConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 15 additions & 8 deletions sep490-idp/src/main/java/sep490/idp/configs/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -82,16 +83,22 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws

http.authorizeHttpRequests(
c -> c
.requestMatchers("/css/**", "/img/**", "/js/**").permitAll()
.requestMatchers(antMatcher("/signup"), antMatcher("/login")).permitAll()
.anyRequest().authenticated());
.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"));

return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
}
}



33 changes: 25 additions & 8 deletions sep490-idp/src/main/java/sep490/idp/controller/MainController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand All @@ -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";
}
}
Original file line number Diff line number Diff line change
@@ -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";
}

}
Original file line number Diff line number Diff line change
@@ -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) {
}
}

Original file line number Diff line number Diff line change
@@ -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) {
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package sep490.idp.dto;

public record ForgotPasswordDTO(String email) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading