Initial codebase commit

This commit is contained in:
2025-05-26 01:59:45 +05:00
parent 73ecd346e8
commit 0f89a1baa7
25 changed files with 1087 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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()
));
}
}

View File

@ -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);
}
}
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -0,0 +1,9 @@
package com.backend.user.service.anyame.entity;
public enum Role {
USER,
MODER,
ADMIN
}

View File

@ -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;
}
}

View File

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

View File

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

View File

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

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,11 @@
jwt:
secret: ${JWT_SECRET}
expiry:
access:
USER: 1d
MODER: 12h
ADMIN: 30m
refresh:
USER: 90d
MODER: 7d
ADMIN: 2h

View File

@ -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() {
}
}