From 96ca31e5365a8dc65dc448deacda24a5b07a1f20 Mon Sep 17 00:00:00 2001 From: bivashy Date: Tue, 27 May 2025 00:45:51 +0500 Subject: [PATCH] Use Privilege-role system instead of enum Role one --- pom.xml | 5 + .../component/AuthorizationProperties.java | 104 ++++++++++++++++++ .../anyame/component/DefaultRoleProvider.java | 28 +++++ .../anyame/component/JwtExpiryProperties.java | 42 ------- .../anyame/component/SetupDataLoader.java | 64 +++++++++++ .../service/anyame/config/SecurityConfig.java | 16 +++ .../anyame/controller/AuthController.java | 16 ++- .../user/service/anyame/dto/AuthResponse.java | 4 +- .../user/service/anyame/entity/Privilege.java | 40 +++++++ .../user/service/anyame/entity/Role.java | 62 ++++++++++- .../user/service/anyame/entity/User.java | 43 +++++--- .../EmailAlreadyExistsException.java | 9 -- .../exception/InvalidRoleException.java | 19 ++++ .../exception/NoExpiryDurationException.java | 5 + .../exception/UserAlreadyExistsException.java | 9 ++ .../repository/PrivilegeRepository.java | 12 ++ .../anyame/repository/RoleRepository.java | 12 ++ .../anyame/repository/UserRepository.java | 4 + .../service/anyame/service/AuthService.java | 34 ++++-- .../anyame/service/AuthorityResolver.java | 58 ++++++++++ .../service/anyame/service/JwtService.java | 53 +++++++-- src/main/resources/application.yaml | 37 +++++-- 22 files changed, 572 insertions(+), 104 deletions(-) create mode 100644 src/main/java/com/backend/user/service/anyame/component/AuthorizationProperties.java create mode 100644 src/main/java/com/backend/user/service/anyame/component/DefaultRoleProvider.java delete mode 100644 src/main/java/com/backend/user/service/anyame/component/JwtExpiryProperties.java create mode 100644 src/main/java/com/backend/user/service/anyame/component/SetupDataLoader.java create mode 100644 src/main/java/com/backend/user/service/anyame/entity/Privilege.java delete mode 100644 src/main/java/com/backend/user/service/anyame/exception/EmailAlreadyExistsException.java create mode 100644 src/main/java/com/backend/user/service/anyame/exception/InvalidRoleException.java create mode 100644 src/main/java/com/backend/user/service/anyame/exception/NoExpiryDurationException.java create mode 100644 src/main/java/com/backend/user/service/anyame/exception/UserAlreadyExistsException.java create mode 100644 src/main/java/com/backend/user/service/anyame/repository/PrivilegeRepository.java create mode 100644 src/main/java/com/backend/user/service/anyame/repository/RoleRepository.java create mode 100644 src/main/java/com/backend/user/service/anyame/service/AuthorityResolver.java diff --git a/pom.xml b/pom.xml index b541229..ba1eac8 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,11 @@ passay ${passay.version} + + org.postgresql + postgresql + runtime + org.springframework.boot diff --git a/src/main/java/com/backend/user/service/anyame/component/AuthorizationProperties.java b/src/main/java/com/backend/user/service/anyame/component/AuthorizationProperties.java new file mode 100644 index 0000000..0fdd990 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/component/AuthorizationProperties.java @@ -0,0 +1,104 @@ +package com.backend.user.service.anyame.component; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +@Component +@ConfigurationProperties(prefix = "authorization") +public class AuthorizationProperties { + + private List roles; + private String hierarchy; + private String defaultRole; + + public List getRoles() { + return roles; + } + + public String getHierarchy() { + return hierarchy; + } + + public String getDefaultRole() { + return defaultRole; + } + + public AuthorizationProperties setRoles(List roles) { + this.roles = roles; + return this; + } + + public AuthorizationProperties setHierarchy(String hierarchy) { + this.hierarchy = hierarchy; + return this; + } + + public AuthorizationProperties setDefaultRole(String defaultRole) { + this.defaultRole = defaultRole; + return this; + } + + public Optional getAccessExpiry(String roleName) { + return roles.stream() + .filter(role -> role.getName().equals(roleName)) + .map(RoleConfig::getAccessExpiry) + .findFirst(); + } + + public Optional getRefreshExpiry(String roleName) { + return roles.stream() + .filter(role -> role.getName().equals(roleName)) + .map(RoleConfig::getRefreshExpiry) + .findFirst(); + } + + public static class RoleConfig { + + private String name; + private Duration accessExpiry; + private Duration refreshExpiry; + private List privileges; + + public String getName() { + return name; + } + + public Duration getAccessExpiry() { + return accessExpiry; + } + + public Duration getRefreshExpiry() { + return refreshExpiry; + } + + public List getPrivileges() { + return privileges; + } + + public RoleConfig setName(String name) { + this.name = name; + return this; + } + + public RoleConfig setAccessExpiry(Duration accessExpiry) { + this.accessExpiry = accessExpiry; + return this; + } + + public RoleConfig setRefreshExpiry(Duration refreshExpiry) { + this.refreshExpiry = refreshExpiry; + return this; + } + + public RoleConfig setPrivileges(List privileges) { + this.privileges = privileges; + return this; + } + + } + +} diff --git a/src/main/java/com/backend/user/service/anyame/component/DefaultRoleProvider.java b/src/main/java/com/backend/user/service/anyame/component/DefaultRoleProvider.java new file mode 100644 index 0000000..11ee60c --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/component/DefaultRoleProvider.java @@ -0,0 +1,28 @@ +package com.backend.user.service.anyame.component; + +import com.backend.user.service.anyame.entity.Role; +import com.backend.user.service.anyame.repository.RoleRepository; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +@Component +public class DefaultRoleProvider { + private final AuthorizationProperties authorizationProperties; + private final RoleRepository repository; + private Role defaultRole; + + public DefaultRoleProvider(AuthorizationProperties authorizationProperties, RoleRepository repository) { + this.authorizationProperties = authorizationProperties; + this.repository = repository; + } + + @PostConstruct + public void init() { + defaultRole = repository.findByName(authorizationProperties.getDefaultRole()).orElseThrow(); + } + + public Role getDefaultRole() { + return defaultRole; + } + +} diff --git a/src/main/java/com/backend/user/service/anyame/component/JwtExpiryProperties.java b/src/main/java/com/backend/user/service/anyame/component/JwtExpiryProperties.java deleted file mode 100644 index 9934d30..0000000 --- a/src/main/java/com/backend/user/service/anyame/component/JwtExpiryProperties.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.backend.user.service.anyame.component; - -import com.backend.user.service.anyame.entity.Role; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; - -@Component -@ConfigurationProperties(prefix = "jwt.expiry") -public class JwtExpiryProperties { - - private Map access = new HashMap<>(); - private Map refresh = new HashMap<>(); - - public Map getAccess() { - return access; - } - - public void setAccess(Map access) { - this.access = access; - } - - public Map getRefresh() { - return refresh; - } - - public void setRefresh(Map refresh) { - this.refresh = refresh; - } - - public Duration getAccessExpiry(Role role) { - return access.getOrDefault(role, Duration.ofDays(1)); - } - - public Duration getRefreshExpiry(Role role) { - return refresh.getOrDefault(role, Duration.ofDays(30)); - } - -} diff --git a/src/main/java/com/backend/user/service/anyame/component/SetupDataLoader.java b/src/main/java/com/backend/user/service/anyame/component/SetupDataLoader.java new file mode 100644 index 0000000..1e727bb --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/component/SetupDataLoader.java @@ -0,0 +1,64 @@ +package com.backend.user.service.anyame.component; + +import com.backend.user.service.anyame.entity.Privilege; +import com.backend.user.service.anyame.entity.Role; +import com.backend.user.service.anyame.repository.PrivilegeRepository; +import com.backend.user.service.anyame.repository.RoleRepository; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Component +public class SetupDataLoader implements + ApplicationListener { + + boolean alreadySetup = false; + private final AuthorizationProperties authorizationProperties; + private final RoleRepository roleRepository; + private final PrivilegeRepository privilegeRepository; + + public SetupDataLoader(AuthorizationProperties authorizationProperties, + RoleRepository roleRepository, + PrivilegeRepository privilegeRepository) { + this.authorizationProperties = authorizationProperties; + this.roleRepository = roleRepository; + this.privilegeRepository = privilegeRepository; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (alreadySetup) + return; + authorizationProperties.getRoles().forEach(roleConfig -> { + List privileges = roleConfig.getPrivileges() + .stream() + .map(this::createPrivilegeIfNotFound) + .toList(); + createRoleIfNotFound(roleConfig.getName(), privileges); + }); + } + + Privilege createPrivilegeIfNotFound(String name) { + Optional privilegeOptional = privilegeRepository.findByName(name); + if (privilegeOptional.isEmpty()) { + privilegeOptional = Optional.of(new Privilege(name)); + privilegeRepository.save(privilegeOptional.get()); + } + return privilegeOptional.get(); + } + + Role createRoleIfNotFound(String name, Collection privileges) { + Optional roleOptional = roleRepository.findByName(name); + if (roleOptional.isEmpty()) { + roleOptional = Optional.of(new Role(name)); + roleOptional.get().setPrivileges(privileges); + roleRepository.save(roleOptional.get()); + } + return roleOptional.get(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/backend/user/service/anyame/config/SecurityConfig.java b/src/main/java/com/backend/user/service/anyame/config/SecurityConfig.java index c1b3130..6049794 100644 --- a/src/main/java/com/backend/user/service/anyame/config/SecurityConfig.java +++ b/src/main/java/com/backend/user/service/anyame/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.backend.user.service.anyame.config; +import com.backend.user.service.anyame.component.AuthorizationProperties; import org.passay.CharacterRule; import org.passay.EnglishCharacterData; import org.passay.LengthRule; @@ -7,14 +8,24 @@ import org.passay.PasswordValidator; import org.passay.WhitespaceRule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; import java.util.Arrays; @Configuration public class SecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable).authorizeHttpRequests(c -> c.anyRequest().permitAll()).build(); + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -32,4 +43,9 @@ public class SecurityConfig { )); } + @Bean + public RoleHierarchy roleHierarchy(AuthorizationProperties authorizationProperties) { + return RoleHierarchyImpl.fromHierarchy(authorizationProperties.getHierarchy()); + } + } diff --git a/src/main/java/com/backend/user/service/anyame/controller/AuthController.java b/src/main/java/com/backend/user/service/anyame/controller/AuthController.java index 2f3070c..6453b89 100644 --- a/src/main/java/com/backend/user/service/anyame/controller/AuthController.java +++ b/src/main/java/com/backend/user/service/anyame/controller/AuthController.java @@ -3,8 +3,10 @@ package com.backend.user.service.anyame.controller; import com.backend.user.service.anyame.dto.AuthResponse; import com.backend.user.service.anyame.dto.LoginRequest; import com.backend.user.service.anyame.dto.RegisterRequest; -import com.backend.user.service.anyame.exception.EmailAlreadyExistsException; +import com.backend.user.service.anyame.exception.UserAlreadyExistsException; import com.backend.user.service.anyame.exception.InvalidCredentialsException; +import com.backend.user.service.anyame.exception.InvalidRoleException; +import com.backend.user.service.anyame.exception.NoExpiryDurationException; import com.backend.user.service.anyame.exception.UnsafePasswordException; import com.backend.user.service.anyame.service.AuthService; import org.springframework.http.HttpStatus; @@ -31,8 +33,12 @@ public class AuthController { return ResponseEntity.ok(authService.register(request)); } catch (UnsafePasswordException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "unsafe password, length 8-64, 1 upper case, 1 lower case, 1 digit, 1 special symbol. No whitespaces in password."); - } catch (EmailAlreadyExistsException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "email already registered"); + } catch (UserAlreadyExistsException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "user already registered"); + } catch (NoExpiryDurationException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "no expiry duration found, invalid server config"); + } catch (InvalidRoleException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "invalid role defined: " + e.getInvalidRole()); } } @@ -42,6 +48,10 @@ public class AuthController { return ResponseEntity.ok(authService.login(request)); } catch (InvalidCredentialsException e) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials", e); + } catch (NoExpiryDurationException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "no expiry duration found, invalid server config"); + } catch (InvalidRoleException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "invalid role defined: " + e.getInvalidRole()); } } diff --git a/src/main/java/com/backend/user/service/anyame/dto/AuthResponse.java b/src/main/java/com/backend/user/service/anyame/dto/AuthResponse.java index abd4a62..fceee93 100644 --- a/src/main/java/com/backend/user/service/anyame/dto/AuthResponse.java +++ b/src/main/java/com/backend/user/service/anyame/dto/AuthResponse.java @@ -1,6 +1,6 @@ package com.backend.user.service.anyame.dto; -import com.backend.user.service.anyame.entity.Role; +import java.util.List; -public record AuthResponse(String accessToken, String refreshToken, long id, String email, String name, Role role) { +public record AuthResponse(String accessToken, String refreshToken, long id, String email, String name, List roles) { } \ No newline at end of file diff --git a/src/main/java/com/backend/user/service/anyame/entity/Privilege.java b/src/main/java/com/backend/user/service/anyame/entity/Privilege.java new file mode 100644 index 0000000..686c895 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/entity/Privilege.java @@ -0,0 +1,40 @@ +package com.backend.user.service.anyame.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; + +import java.util.Collection; + +@Entity +public class Privilege { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + private String name; + @ManyToMany(mappedBy = "privileges") + private Collection roles; + + protected Privilege() { + } + + public Privilege(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Collection getRoles() { + return roles; + } + +} \ No newline at end of file diff --git a/src/main/java/com/backend/user/service/anyame/entity/Role.java b/src/main/java/com/backend/user/service/anyame/entity/Role.java index bab4465..8779eb8 100644 --- a/src/main/java/com/backend/user/service/anyame/entity/Role.java +++ b/src/main/java/com/backend/user/service/anyame/entity/Role.java @@ -1,9 +1,61 @@ package com.backend.user.service.anyame.entity; -public enum Role { - USER, - MODER, - ADMIN +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import java.util.Collection; -} +@Entity +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + @ManyToMany(mappedBy = "roles") + private Collection users; + + @ManyToMany + @JoinTable( + name = "roles_privileges", + joinColumns = @JoinColumn( + name = "role_id", referencedColumnName = "id"), + inverseJoinColumns = @JoinColumn( + name = "privilege_id", referencedColumnName = "id")) + private Collection privileges; + + protected Role() { + } + + public Role(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Collection getUsers() { + return users; + } + + public Collection getPrivileges() { + return privileges; + } + + public Role setPrivileges(Collection privileges) { + this.privileges = privileges; + return this; + } + +} \ No newline at end of file diff --git a/src/main/java/com/backend/user/service/anyame/entity/User.java b/src/main/java/com/backend/user/service/anyame/entity/User.java index a0b359b..dd41ca9 100644 --- a/src/main/java/com/backend/user/service/anyame/entity/User.java +++ b/src/main/java/com/backend/user/service/anyame/entity/User.java @@ -2,13 +2,16 @@ package com.backend.user.service.anyame.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import jakarta.persistence.Table; +import java.util.Collection; + @Entity @Table(name = "users") public class User { @@ -20,24 +23,29 @@ public class User { @Column(unique = true, nullable = false) private String email; private String password; - @Enumerated(EnumType.STRING) - private Role role; - private boolean emailVerified = true; + private boolean enabled = true; + + @ManyToMany + @JoinTable( + name = "users_roles", + joinColumns = @JoinColumn( + name = "user_id", referencedColumnName = "id"), + inverseJoinColumns = @JoinColumn( + name = "role_id", referencedColumnName = "id")) + private Collection roles; protected User() { } - public User(String name, String email, String password, Role role) { + public User(String name, String email, String password) { this.name = name; this.email = email; this.password = password; - this.role = role; } - public User(String name, String email, Role role) { + public User(String name, String email) { this.name = name; this.email = email; - this.role = role; } public Long getId() { @@ -61,16 +69,21 @@ public class User { return this; } - public Role getRole() { - return role; + public boolean isEnabled() { + return enabled; } - public boolean isEmailVerified() { - return emailVerified; + public User setEnabled(boolean enabled) { + this.enabled = enabled; + return this; } - public User setEmailVerified(boolean emailVerified) { - this.emailVerified = emailVerified; + public Collection getRoles() { + return roles; + } + + public User setRoles(Collection roles) { + this.roles = roles; return this; } diff --git a/src/main/java/com/backend/user/service/anyame/exception/EmailAlreadyExistsException.java b/src/main/java/com/backend/user/service/anyame/exception/EmailAlreadyExistsException.java deleted file mode 100644 index 20e35cb..0000000 --- a/src/main/java/com/backend/user/service/anyame/exception/EmailAlreadyExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.backend.user.service.anyame.exception; - -public class EmailAlreadyExistsException extends Exception { - - public EmailAlreadyExistsException() { - super("email already exists"); - } - -} diff --git a/src/main/java/com/backend/user/service/anyame/exception/InvalidRoleException.java b/src/main/java/com/backend/user/service/anyame/exception/InvalidRoleException.java new file mode 100644 index 0000000..153e343 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/exception/InvalidRoleException.java @@ -0,0 +1,19 @@ +package com.backend.user.service.anyame.exception; + +public class InvalidRoleException extends Exception { + private final String invalidRole; + + public InvalidRoleException(String invalidRole) { + this.invalidRole = invalidRole; + } + + public InvalidRoleException(String message, String invalidRole) { + super(message); + this.invalidRole = invalidRole; + } + + public String getInvalidRole() { + return invalidRole; + } + +} diff --git a/src/main/java/com/backend/user/service/anyame/exception/NoExpiryDurationException.java b/src/main/java/com/backend/user/service/anyame/exception/NoExpiryDurationException.java new file mode 100644 index 0000000..f159331 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/exception/NoExpiryDurationException.java @@ -0,0 +1,5 @@ +package com.backend.user.service.anyame.exception; + +public class NoExpiryDurationException extends Exception { + +} diff --git a/src/main/java/com/backend/user/service/anyame/exception/UserAlreadyExistsException.java b/src/main/java/com/backend/user/service/anyame/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..fd83018 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/exception/UserAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.backend.user.service.anyame.exception; + +public class UserAlreadyExistsException extends Exception { + + public UserAlreadyExistsException() { + super("user already exists"); + } + +} diff --git a/src/main/java/com/backend/user/service/anyame/repository/PrivilegeRepository.java b/src/main/java/com/backend/user/service/anyame/repository/PrivilegeRepository.java new file mode 100644 index 0000000..4f8a585 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/repository/PrivilegeRepository.java @@ -0,0 +1,12 @@ +package com.backend.user.service.anyame.repository; + +import com.backend.user.service.anyame.entity.Privilege; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PrivilegeRepository extends JpaRepository { + + Optional findByName(String name); + +} diff --git a/src/main/java/com/backend/user/service/anyame/repository/RoleRepository.java b/src/main/java/com/backend/user/service/anyame/repository/RoleRepository.java new file mode 100644 index 0000000..4204795 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/repository/RoleRepository.java @@ -0,0 +1,12 @@ +package com.backend.user.service.anyame.repository; + +import com.backend.user.service.anyame.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RoleRepository extends JpaRepository { + + Optional findByName(String roleName); + +} diff --git a/src/main/java/com/backend/user/service/anyame/repository/UserRepository.java b/src/main/java/com/backend/user/service/anyame/repository/UserRepository.java index a8f7147..0663e18 100644 --- a/src/main/java/com/backend/user/service/anyame/repository/UserRepository.java +++ b/src/main/java/com/backend/user/service/anyame/repository/UserRepository.java @@ -6,5 +6,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + Optional findByEmailOrName(String email, String name); + } diff --git a/src/main/java/com/backend/user/service/anyame/service/AuthService.java b/src/main/java/com/backend/user/service/anyame/service/AuthService.java index 70e58b8..8195f97 100644 --- a/src/main/java/com/backend/user/service/anyame/service/AuthService.java +++ b/src/main/java/com/backend/user/service/anyame/service/AuthService.java @@ -1,17 +1,23 @@ package com.backend.user.service.anyame.service; +import com.backend.user.service.anyame.component.DefaultRoleProvider; import com.backend.user.service.anyame.dto.AuthResponse; import com.backend.user.service.anyame.dto.LoginRequest; import com.backend.user.service.anyame.dto.RegisterRequest; import com.backend.user.service.anyame.entity.Role; import com.backend.user.service.anyame.entity.User; -import com.backend.user.service.anyame.exception.EmailAlreadyExistsException; +import com.backend.user.service.anyame.exception.UserAlreadyExistsException; import com.backend.user.service.anyame.exception.InvalidCredentialsException; +import com.backend.user.service.anyame.exception.InvalidRoleException; +import com.backend.user.service.anyame.exception.NoExpiryDurationException; import com.backend.user.service.anyame.exception.UnsafePasswordException; import com.backend.user.service.anyame.repository.UserRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.Collections; +import java.util.List; + @Service public class AuthService { @@ -19,20 +25,28 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final PasswordValidatorService passwordValidator; + private final DefaultRoleProvider defaultRoleProvider; - public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService, PasswordValidatorService passwordValidator) { + public AuthService(UserRepository userRepository, + PasswordEncoder passwordEncoder, + JwtService jwtService, + PasswordValidatorService passwordValidator, + DefaultRoleProvider defaultRoleProvider) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.jwtService = jwtService; this.passwordValidator = passwordValidator; + this.defaultRoleProvider = defaultRoleProvider; } - public AuthResponse register(RegisterRequest request) throws UnsafePasswordException, EmailAlreadyExistsException { - if (userRepository.findByEmail(request.email()).isPresent()) { - throw new EmailAlreadyExistsException(); + public AuthResponse register( + RegisterRequest request) throws UnsafePasswordException, UserAlreadyExistsException, NoExpiryDurationException, InvalidRoleException { + if (userRepository.findByEmailOrName(request.email(), request.name()).isPresent()) { + throw new UserAlreadyExistsException(); } - User user = new User(request.name(), request.email(), Role.USER); + User user = new User(request.name(), request.email()); + user.setRoles(Collections.singleton(defaultRoleProvider.getDefaultRole())); if (request.password() != null && !request.password().isBlank()) { if (!passwordValidator.validate(request.password())) { @@ -40,11 +54,12 @@ public class AuthService { } user.setPassword(passwordEncoder.encode(request.password())); } + user = userRepository.save(user); return generateAuthResponse(user); } - public AuthResponse login(LoginRequest request) throws InvalidCredentialsException { + public AuthResponse login(LoginRequest request) throws InvalidCredentialsException, NoExpiryDurationException, InvalidRoleException { User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new RuntimeException("Invalid credentials")); @@ -55,10 +70,11 @@ public class AuthService { return generateAuthResponse(user); } - private AuthResponse generateAuthResponse(User user) { + private AuthResponse generateAuthResponse(User user) throws NoExpiryDurationException, InvalidRoleException { String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); - return new AuthResponse(accessToken, refreshToken, user.getId(), user.getEmail(), user.getName(), user.getRole()); + List roles = user.getRoles().stream().map(Role::getName).toList(); + return new AuthResponse(accessToken, refreshToken, user.getId(), user.getEmail(), user.getName(), roles); } } diff --git a/src/main/java/com/backend/user/service/anyame/service/AuthorityResolver.java b/src/main/java/com/backend/user/service/anyame/service/AuthorityResolver.java new file mode 100644 index 0000000..7679150 --- /dev/null +++ b/src/main/java/com/backend/user/service/anyame/service/AuthorityResolver.java @@ -0,0 +1,58 @@ +package com.backend.user.service.anyame.service; + +import com.backend.user.service.anyame.entity.Privilege; +import com.backend.user.service.anyame.entity.Role; +import com.backend.user.service.anyame.entity.User; +import com.backend.user.service.anyame.exception.InvalidRoleException; +import com.backend.user.service.anyame.repository.RoleRepository; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class AuthorityResolver { + private final RoleRepository roleRepository; + private final RoleHierarchy roleHierarchy; + + public AuthorityResolver(RoleRepository roleRepository, RoleHierarchy roleHierarchy) { + this.roleRepository = roleRepository; + this.roleHierarchy = roleHierarchy; + } + + public List getAuthorities(User user) throws InvalidRoleException { + List grantedAuthorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getName())) + .collect(Collectors.toList()); + + Collection reachableAuthorities = + roleHierarchy.getReachableGrantedAuthorities(grantedAuthorities); + + Set privileges = new HashSet<>(); + for (GrantedAuthority authority : reachableAuthorities) { + String roleName = authority.getAuthority(); + Role role = roleRepository.findByName(roleName) + .orElseThrow(() -> new InvalidRoleException(roleName)); + if (role != null && role.getPrivileges() != null) { + privileges.addAll(role.getPrivileges().stream() + .map(Privilege::getName) + .collect(Collectors.toSet())); + } + } + + Set allAuthorities = reachableAuthorities.stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + allAuthorities.addAll(privileges); + + return new ArrayList<>(allAuthorities); + } + +} diff --git a/src/main/java/com/backend/user/service/anyame/service/JwtService.java b/src/main/java/com/backend/user/service/anyame/service/JwtService.java index 579c137..f601764 100644 --- a/src/main/java/com/backend/user/service/anyame/service/JwtService.java +++ b/src/main/java/com/backend/user/service/anyame/service/JwtService.java @@ -1,36 +1,54 @@ package com.backend.user.service.anyame.service; +import com.backend.user.service.anyame.component.AuthorizationProperties; import com.backend.user.service.anyame.component.JWTSecretProvider; -import com.backend.user.service.anyame.component.JwtExpiryProperties; +import com.backend.user.service.anyame.entity.Role; import com.backend.user.service.anyame.entity.User; +import com.backend.user.service.anyame.exception.InvalidRoleException; +import com.backend.user.service.anyame.exception.NoExpiryDurationException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; @Service public class JwtService { private final JWTSecretProvider secretProvider; - private final JwtExpiryProperties expiryProperties; + private final AuthorizationProperties authorizationProperties; + private final AuthorityResolver authorityResolver; - public JwtService(JWTSecretProvider secretProvider, JwtExpiryProperties expiryProperties) { + public JwtService(JWTSecretProvider secretProvider, + AuthorizationProperties authorizationProperties, + AuthorityResolver authorityResolver) { this.secretProvider = secretProvider; - this.expiryProperties = expiryProperties; + this.authorizationProperties = authorizationProperties; + this.authorityResolver = authorityResolver; } - public String generateRefreshToken(User user) { + public String generateRefreshToken(User user) throws InvalidRoleException, NoExpiryDurationException { + List authorities = authorityResolver.getAuthorities(user); + List roles = user.getRoles().stream() + .map(Role::getName) + .toList(); JwtBuilder builder = Jwts.builder() .subject(user.getEmail()) .claim("name", user.getName()) .claim("id", user.getId()) - .claim("role", user.getRole()) + .claim("roles", roles) + .claim("authorities", authorities) .claim("type", "refresh"); - Duration refreshExpiry = expiryProperties.getRefreshExpiry(user.getRole()); + Duration refreshExpiry = getMaxExpiry(user, + role -> authorizationProperties.getRefreshExpiry(role.getName())) + .orElseThrow(NoExpiryDurationException::new); Date issuedAt = new Date(); Date expiryDate = Date.from(Instant.now().plus(refreshExpiry)); @@ -41,14 +59,21 @@ public class JwtService { .compact(); } - public String generateAccessToken(User user) { + public String generateAccessToken(User user) throws InvalidRoleException, NoExpiryDurationException { + List authorities = authorityResolver.getAuthorities(user); + List roles = user.getRoles().stream() + .map(Role::getName) + .toList(); JwtBuilder builder = Jwts.builder() .subject(user.getEmail()) .claim("name", user.getName()) .claim("id", user.getId()) - .claim("role", user.getRole()) + .claim("roles", roles) + .claim("authorities", authorities) .claim("type", "access"); - Duration accessExpiry = expiryProperties.getAccessExpiry(user.getRole()); + Duration accessExpiry = getMaxExpiry(user, + role -> authorizationProperties.getAccessExpiry(role.getName())) + .orElseThrow(NoExpiryDurationException::new); Date issuedAt = new Date(); Date expiryDate = Date.from(Instant.now().plus(accessExpiry)); @@ -59,6 +84,14 @@ public class JwtService { .compact(); } + private Optional getMaxExpiry(User user, Function> mapper) { + return user.getRoles().stream() + .map(mapper) + .filter(Optional::isPresent) + .map(Optional::get) + .max(Duration::compareTo); + } + public String extractEmail(String token) { return getClaims(token).getSubject(); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7954564..4d4044c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,11 +1,30 @@ jwt: secret: ${JWT_SECRET} - expiry: - access: - USER: 1d - MODER: 12h - ADMIN: 30m - refresh: - USER: 90d - MODER: 7d - ADMIN: 2h \ No newline at end of file +authorization: + roles: + - name: ROLE_USER + access-expiry: '1d' + refresh-expiry: '90d' + privileges: + - READ_PRIVILEGE + - name: ROLE_ADMIN + access-expiry: '30m' + refresh-expiry: '2h' + privileges: + - WRITE_PRIVILEGE + - CHANGE_PASSWORD_PRIVILEGE + hierarchy: | + ROLE_ADMIN > ROLE_USER + default-role: ROLE_USER + +spring: + datasource: + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + jpa: + hibernate: + ddl-auto: none +server: + error: + include-message: always