Initial implementation of OAuth2 server with OIDC

This commit is contained in:
2025-06-08 20:51:59 +05:00
parent 96ca31e536
commit 958521526b
30 changed files with 240 additions and 913 deletions

View File

@ -12,7 +12,7 @@
<artifactId>anyame-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>anyame-backend</name>
<description>User service for anyame backend</description>
<description>User service for anyame</description>
<url/>
<licenses>
<license/>
@ -50,6 +50,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>

View File

@ -1,104 +0,0 @@
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<RoleConfig> roles;
private String hierarchy;
private String defaultRole;
public List<RoleConfig> getRoles() {
return roles;
}
public String getHierarchy() {
return hierarchy;
}
public String getDefaultRole() {
return defaultRole;
}
public AuthorizationProperties setRoles(List<RoleConfig> 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<Duration> getAccessExpiry(String roleName) {
return roles.stream()
.filter(role -> role.getName().equals(roleName))
.map(RoleConfig::getAccessExpiry)
.findFirst();
}
public Optional<Duration> 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<String> privileges;
public String getName() {
return name;
}
public Duration getAccessExpiry() {
return accessExpiry;
}
public Duration getRefreshExpiry() {
return refreshExpiry;
}
public List<String> 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<String> privileges) {
this.privileges = privileges;
return this;
}
}
}

View File

@ -0,0 +1,31 @@
package com.backend.user.service.anyame.component;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {
private String issuerUrl;
private String introspectionEndpoint;
public String getIssuerUrl() {
return issuerUrl;
}
public AuthorizationServerProperties setIssuerUrl(String issuerUrl) {
this.issuerUrl = issuerUrl;
return this;
}
public String getIntrospectionEndpoint() {
return introspectionEndpoint;
}
public AuthorizationServerProperties setIntrospectionEndpoint(String introspectionEndpoint) {
this.introspectionEndpoint = introspectionEndpoint;
return this;
}
}

View File

@ -1,28 +0,0 @@
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;
}
}

View File

@ -1,27 +0,0 @@
package com.backend.user.service.anyame.component;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
@Component
public class JWTSecretProvider {
@Value("${jwt.secret}")
private String jwtSecret;
private SecretKey secretKey;
@PostConstruct
public void init() {
secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
}
public SecretKey getSecretKey() {
return secretKey;
}
}

View File

@ -1,64 +0,0 @@
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<ContextRefreshedEvent> {
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<Privilege> privileges = roleConfig.getPrivileges()
.stream()
.map(this::createPrivilegeIfNotFound)
.toList();
createRoleIfNotFound(roleConfig.getName(), privileges);
});
}
Privilege createPrivilegeIfNotFound(String name) {
Optional<Privilege> 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<Privilege> privileges) {
Optional<Role> roleOptional = roleRepository.findByName(name);
if (roleOptional.isEmpty()) {
roleOptional = Optional.of(new Role(name));
roleOptional.get().setPrivileges(privileges);
roleRepository.save(roleOptional.get());
}
return roleOptional.get();
}
}

View File

@ -0,0 +1,105 @@
package com.backend.user.service.anyame.config;
import com.backend.user.service.anyame.component.AuthorizationServerProperties;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfigurationSource;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
private final AuthorizationServerProperties authorizationServerProperties;
public AuthorizationServerConfig(AuthorizationServerProperties authorizationServerProperties) {
this.authorizationServerProperties = authorizationServerProperties;
}
@Bean
@Order(1)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http, @Qualifier("corsConfigurationSource") CorsConfigurationSource configurationSource) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
OAuth2AuthorizationServerConfigurer.authorizationServer();
return http
.cors(c -> c.configurationSource(configurationSource))
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(authorizationServerConfigurer, (authorizationServer) ->
authorizationServer
.oidc(Customizer.withDefaults())
)
.authorizeHttpRequests((authorize) ->
authorize
.anyRequest().authenticated()
)
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.ALL)
)
)
.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oidc-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:5173/code")
.postLogoutRedirectUri("http://localhost:5173/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
.accessTokenTimeToLive(Duration.of(30, ChronoUnit.MINUTES))
.refreshTokenTimeToLive(Duration.of(120, ChronoUnit.MINUTES))
.reuseRefreshTokens(false)
.authorizationCodeTimeToLive(Duration.of(30, ChronoUnit.SECONDS))
.build())
.build();
return new InMemoryRegisteredClientRepository(oidcClient);
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer(authorizationServerProperties.getIssuerUrl())
.tokenIntrospectionEndpoint(authorizationServerProperties.getIntrospectionEndpoint())
.build();
}
}

View File

@ -1,51 +1,42 @@
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;
import org.passay.PasswordValidator;
import org.passay.WhitespaceRule;
import org.springframework.beans.factory.annotation.Qualifier;
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.core.annotation.Order;
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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable).authorizeHttpRequests(c -> c.anyRequest().permitAll()).build();
@Order(2)
public SecurityFilterChain filterChain(HttpSecurity http, @Qualifier("corsConfigurationSource") CorsConfigurationSource configurationSource) throws Exception {
return http
.cors(c -> c.configurationSource(configurationSource))
.authorizeHttpRequests(c ->
c.anyRequest().authenticated()
)
.formLogin(withDefaults())
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public PasswordValidator passwordValidator() {
return new PasswordValidator(Arrays.asList(
new LengthRule(8, 64),
new CharacterRule(EnglishCharacterData.UpperCase, 1),
new CharacterRule(EnglishCharacterData.LowerCase, 1),
new CharacterRule(EnglishCharacterData.Digit, 1),
new CharacterRule(EnglishCharacterData.Special, 1),
new WhitespaceRule()
));
}
@Bean
public RoleHierarchy roleHierarchy(AuthorizationProperties authorizationProperties) {
return RoleHierarchyImpl.fromHierarchy(authorizationProperties.getHierarchy());
public UserDetailsService users() {
UserDetails user = User.builder()
.username("admin")
.password("{noop}password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}

View File

@ -0,0 +1,38 @@
package com.backend.user.service.anyame.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
@Bean(name = "corsConfigurationSource")
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@ -1,58 +0,0 @@
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.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;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
try {
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 (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());
}
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
try {
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());
}
}
}

View File

@ -0,0 +1,14 @@
package com.backend.user.service.anyame.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello World";
}
}

View File

@ -1,6 +0,0 @@
package com.backend.user.service.anyame.dto;
import java.util.List;
public record AuthResponse(String accessToken, String refreshToken, long id, String email, String name, List<String> roles) {
}

View File

@ -1,14 +0,0 @@
package com.backend.user.service.anyame.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record LoginRequest(
@NotBlank(message = "email must not be blank")
@Email(message = "email must be valid")
String email,
@NotNull(message = "password must not be null, but can be blank")
String password) {
}

View File

@ -1,17 +0,0 @@
package com.backend.user.service.anyame.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank(message = "name must not be blank")
@Size(min = 3, max = 16, message = "name min length is 3, max is 16")
String name,
@NotBlank(message = "email must not be blank")
@Email(message = "email must be valid")
String email,
@NotNull(message = "password must not be null, but can be blank")
String password) {
}

View File

@ -1,40 +0,0 @@
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<Role> roles;
protected Privilege() {
}
public Privilege(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Collection<Role> getRoles() {
return roles;
}
}

View File

@ -1,61 +0,0 @@
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.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<User> users;
@ManyToMany
@JoinTable(
name = "roles_privileges",
joinColumns = @JoinColumn(
name = "role_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(
name = "privilege_id", referencedColumnName = "id"))
private Collection<Privilege> privileges;
protected Role() {
}
public Role(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Collection<User> getUsers() {
return users;
}
public Collection<Privilege> getPrivileges() {
return privileges;
}
public Role setPrivileges(Collection<Privilege> privileges) {
this.privileges = privileges;
return this;
}
}

View File

@ -1,91 +0,0 @@
package com.backend.user.service.anyame.entity;
import jakarta.persistence.Column;
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 jakarta.persistence.Table;
import java.util.Collection;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(unique = true, nullable = false)
private String email;
private String password;
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<Role> roles;
protected User() {
}
public User(String name, String email, String password) {
this.name = name;
this.email = email;
this.password = password;
}
public User(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
public User setPassword(String password) {
this.password = password;
return this;
}
public boolean isEnabled() {
return enabled;
}
public User setEnabled(boolean enabled) {
this.enabled = enabled;
return this;
}
public Collection<Role> getRoles() {
return roles;
}
public User setRoles(Collection<Role> roles) {
this.roles = roles;
return this;
}
}

View File

@ -1,9 +0,0 @@
package com.backend.user.service.anyame.exception;
public class InvalidCredentialsException extends Exception {
public InvalidCredentialsException() {
super("invalid credentials");
}
}

View File

@ -1,19 +0,0 @@
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;
}
}

View File

@ -1,5 +0,0 @@
package com.backend.user.service.anyame.exception;
public class NoExpiryDurationException extends Exception {
}

View File

@ -1,9 +0,0 @@
package com.backend.user.service.anyame.exception;
public class UnsafePasswordException extends Exception {
public UnsafePasswordException(String message) {
super(message);
}
}

View File

@ -1,9 +0,0 @@
package com.backend.user.service.anyame.exception;
public class UserAlreadyExistsException extends Exception {
public UserAlreadyExistsException() {
super("user already exists");
}
}

View File

@ -1,12 +0,0 @@
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<Privilege, Long> {
Optional<Privilege> findByName(String name);
}

View File

@ -1,12 +0,0 @@
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<Role, Long> {
Optional<Role> findByName(String roleName);
}

View File

@ -1,14 +0,0 @@
package com.backend.user.service.anyame.repository;
import com.backend.user.service.anyame.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByEmailOrName(String email, String name);
}

View File

@ -1,80 +0,0 @@
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.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 {
private final UserRepository userRepository;
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,
DefaultRoleProvider defaultRoleProvider) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.passwordValidator = passwordValidator;
this.defaultRoleProvider = defaultRoleProvider;
}
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());
user.setRoles(Collections.singleton(defaultRoleProvider.getDefaultRole()));
if (request.password() != null && !request.password().isBlank()) {
if (!passwordValidator.validate(request.password())) {
throw new UnsafePasswordException("unsafe password");
}
user.setPassword(passwordEncoder.encode(request.password()));
}
user = userRepository.save(user);
return generateAuthResponse(user);
}
public AuthResponse login(LoginRequest request) throws InvalidCredentialsException, NoExpiryDurationException, InvalidRoleException {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new RuntimeException("Invalid credentials"));
if (user.getPassword() == null || !passwordEncoder.matches(request.password(), user.getPassword())) {
throw new InvalidCredentialsException();
}
return generateAuthResponse(user);
}
private AuthResponse generateAuthResponse(User user) throws NoExpiryDurationException, InvalidRoleException {
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
List<String> roles = user.getRoles().stream().map(Role::getName).toList();
return new AuthResponse(accessToken, refreshToken, user.getId(), user.getEmail(), user.getName(), roles);
}
}

View File

@ -1,58 +0,0 @@
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<String> getAuthorities(User user) throws InvalidRoleException {
List<GrantedAuthority> grantedAuthorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
Collection<? extends GrantedAuthority> reachableAuthorities =
roleHierarchy.getReachableGrantedAuthorities(grantedAuthorities);
Set<String> 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<String> allAuthorities = reachableAuthorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
allAuthorities.addAll(privileges);
return new ArrayList<>(allAuthorities);
}
}

View File

@ -1,115 +0,0 @@
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.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 AuthorizationProperties authorizationProperties;
private final AuthorityResolver authorityResolver;
public JwtService(JWTSecretProvider secretProvider,
AuthorizationProperties authorizationProperties,
AuthorityResolver authorityResolver) {
this.secretProvider = secretProvider;
this.authorizationProperties = authorizationProperties;
this.authorityResolver = authorityResolver;
}
public String generateRefreshToken(User user) throws InvalidRoleException, NoExpiryDurationException {
List<String> authorities = authorityResolver.getAuthorities(user);
List<String> roles = user.getRoles().stream()
.map(Role::getName)
.toList();
JwtBuilder builder = Jwts.builder()
.subject(user.getEmail())
.claim("name", user.getName())
.claim("id", user.getId())
.claim("roles", roles)
.claim("authorities", authorities)
.claim("type", "refresh");
Duration refreshExpiry = getMaxExpiry(user,
role -> authorizationProperties.getRefreshExpiry(role.getName()))
.orElseThrow(NoExpiryDurationException::new);
Date issuedAt = new Date();
Date expiryDate = Date.from(Instant.now().plus(refreshExpiry));
return builder.issuedAt(issuedAt)
.expiration(expiryDate)
.signWith(secretProvider.getSecretKey())
.compact();
}
public String generateAccessToken(User user) throws InvalidRoleException, NoExpiryDurationException {
List<String> authorities = authorityResolver.getAuthorities(user);
List<String> roles = user.getRoles().stream()
.map(Role::getName)
.toList();
JwtBuilder builder = Jwts.builder()
.subject(user.getEmail())
.claim("name", user.getName())
.claim("id", user.getId())
.claim("roles", roles)
.claim("authorities", authorities)
.claim("type", "access");
Duration accessExpiry = getMaxExpiry(user,
role -> authorizationProperties.getAccessExpiry(role.getName()))
.orElseThrow(NoExpiryDurationException::new);
Date issuedAt = new Date();
Date expiryDate = Date.from(Instant.now().plus(accessExpiry));
return builder.issuedAt(issuedAt)
.expiration(expiryDate)
.signWith(secretProvider.getSecretKey())
.compact();
}
private Optional<Duration> getMaxExpiry(User user, Function<Role, Optional<Duration>> 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();
}
public boolean isTokenValid(String token, User user) {
return extractEmail(token).equals(user.getEmail()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return getClaims(token).getExpiration().before(new Date());
}
private Claims getClaims(String token) {
return Jwts.parser()
.decryptWith(secretProvider.getSecretKey())
.build()
.parseEncryptedClaims(token)
.getPayload();
}
}

View File

@ -1,23 +0,0 @@
package com.backend.user.service.anyame.service;
import org.passay.PasswordData;
import org.passay.PasswordValidator;
import org.passay.RuleResult;
import org.springframework.stereotype.Service;
@Service
public class PasswordValidatorService {
private final PasswordValidator passwordValidator;
public PasswordValidatorService(PasswordValidator passwordValidator) {
this.passwordValidator = passwordValidator;
}
public boolean validate(String password) {
RuleResult result = passwordValidator.validate(new PasswordData(password));
// TODO: Add HaveBeenIPwned support?
return result.isValid();
}
}

View File

@ -18,13 +18,32 @@ authorization:
default-role: ROLE_USER
spring:
application:
name: anyame-user-service
security:
oauth2:
authorizationserver:
issuer-url: http://localhost:8080
introspection-endpoint: /oauth2/token-info
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
jpa:
hibernate:
ddl-auto: none
ddl-auto: create
logging:
level:
root: DEBUG
org.springframework.security: DEBUG
logging.level.org.springframework.web: DEBUG
logging.level.org.springframework.security.oauth2: TRACE
org.apache.tomcat.util.net.NioEndpoint: ERROR
sun.rmi: ERROR
java.io: ERROR
javax.management: ERROR
server:
error:
include-message: always