Initial codebase commit
This commit is contained in:
@ -0,0 +1,13 @@
|
||||
package com.backend.user.service.anyame;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class AnyameBackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AnyameBackendApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
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<Role, Duration> access = new HashMap<>();
|
||||
private Map<Role, Duration> refresh = new HashMap<>();
|
||||
|
||||
public Map<Role, Duration> getAccess() {
|
||||
return access;
|
||||
}
|
||||
|
||||
public void setAccess(Map<Role, Duration> access) {
|
||||
this.access = access;
|
||||
}
|
||||
|
||||
public Map<Role, Duration> getRefresh() {
|
||||
return refresh;
|
||||
}
|
||||
|
||||
public void setRefresh(Map<Role, Duration> 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));
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package com.backend.user.service.anyame.config;
|
||||
|
||||
import org.passay.CharacterRule;
|
||||
import org.passay.EnglishCharacterData;
|
||||
import org.passay.LengthRule;
|
||||
import org.passay.PasswordValidator;
|
||||
import org.passay.WhitespaceRule;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@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()
|
||||
));
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
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.InvalidCredentialsException;
|
||||
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 (EmailAlreadyExistsException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "email already registered");
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package com.backend.user.service.anyame.dto;
|
||||
|
||||
import com.backend.user.service.anyame.entity.Role;
|
||||
|
||||
public record AuthResponse(String accessToken, String refreshToken, long id, String email, String name, Role role) {
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
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) {
|
||||
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
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) {
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package com.backend.user.service.anyame.entity;
|
||||
|
||||
public enum Role {
|
||||
USER,
|
||||
MODER,
|
||||
ADMIN
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
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.Table;
|
||||
|
||||
@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;
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Role role;
|
||||
private boolean emailVerified = true;
|
||||
|
||||
protected User() {
|
||||
}
|
||||
|
||||
public User(String name, String email, String password, Role role) {
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.password = password;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public User(String name, String email, Role role) {
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
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 Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public boolean isEmailVerified() {
|
||||
return emailVerified;
|
||||
}
|
||||
|
||||
public User setEmailVerified(boolean emailVerified) {
|
||||
this.emailVerified = emailVerified;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,9 @@
|
||||
package com.backend.user.service.anyame.exception;
|
||||
|
||||
public class EmailAlreadyExistsException extends Exception {
|
||||
|
||||
public EmailAlreadyExistsException() {
|
||||
super("email already exists");
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package com.backend.user.service.anyame.exception;
|
||||
|
||||
public class InvalidCredentialsException extends Exception {
|
||||
|
||||
public InvalidCredentialsException() {
|
||||
super("invalid credentials");
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package com.backend.user.service.anyame.exception;
|
||||
|
||||
public class UnsafePasswordException extends Exception {
|
||||
|
||||
public UnsafePasswordException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
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);
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
package com.backend.user.service.anyame.service;
|
||||
|
||||
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.InvalidCredentialsException;
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordValidatorService passwordValidator;
|
||||
|
||||
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService, PasswordValidatorService passwordValidator) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.passwordValidator = passwordValidator;
|
||||
}
|
||||
|
||||
public AuthResponse register(RegisterRequest request) throws UnsafePasswordException, EmailAlreadyExistsException {
|
||||
if (userRepository.findByEmail(request.email()).isPresent()) {
|
||||
throw new EmailAlreadyExistsException();
|
||||
}
|
||||
|
||||
User user = new User(request.name(), request.email(), Role.USER);
|
||||
|
||||
if (request.password() != null && !request.password().isBlank()) {
|
||||
if (!passwordValidator.validate(request.password())) {
|
||||
throw new UnsafePasswordException("unsafe password");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(request.password()));
|
||||
}
|
||||
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) throws InvalidCredentialsException {
|
||||
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) {
|
||||
String accessToken = jwtService.generateAccessToken(user);
|
||||
String refreshToken = jwtService.generateRefreshToken(user);
|
||||
return new AuthResponse(accessToken, refreshToken, user.getId(), user.getEmail(), user.getName(), user.getRole());
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package com.backend.user.service.anyame.service;
|
||||
|
||||
import com.backend.user.service.anyame.component.JWTSecretProvider;
|
||||
import com.backend.user.service.anyame.component.JwtExpiryProperties;
|
||||
import com.backend.user.service.anyame.entity.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtBuilder;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
private final JWTSecretProvider secretProvider;
|
||||
private final JwtExpiryProperties expiryProperties;
|
||||
|
||||
public JwtService(JWTSecretProvider secretProvider, JwtExpiryProperties expiryProperties) {
|
||||
this.secretProvider = secretProvider;
|
||||
this.expiryProperties = expiryProperties;
|
||||
}
|
||||
|
||||
public String generateRefreshToken(User user) {
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
.subject(user.getEmail())
|
||||
.claim("name", user.getName())
|
||||
.claim("id", user.getId())
|
||||
.claim("role", user.getRole())
|
||||
.claim("type", "refresh");
|
||||
Duration refreshExpiry = expiryProperties.getRefreshExpiry(user.getRole());
|
||||
|
||||
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) {
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
.subject(user.getEmail())
|
||||
.claim("name", user.getName())
|
||||
.claim("id", user.getId())
|
||||
.claim("role", user.getRole())
|
||||
.claim("type", "access");
|
||||
Duration accessExpiry = expiryProperties.getAccessExpiry(user.getRole());
|
||||
|
||||
Date issuedAt = new Date();
|
||||
Date expiryDate = Date.from(Instant.now().plus(accessExpiry));
|
||||
|
||||
return builder.issuedAt(issuedAt)
|
||||
.expiration(expiryDate)
|
||||
.signWith(secretProvider.getSecretKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
11
src/main/resources/application.yaml
Normal file
11
src/main/resources/application.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiry:
|
||||
access:
|
||||
USER: 1d
|
||||
MODER: 12h
|
||||
ADMIN: 30m
|
||||
refresh:
|
||||
USER: 90d
|
||||
MODER: 7d
|
||||
ADMIN: 2h
|
@ -0,0 +1,13 @@
|
||||
package com.backend.user.service.anyame;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class AnyameBackendApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user