diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/AccessTokenEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/AccessTokenEntity.java new file mode 100644 index 0000000..45eeb66 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/AccessTokenEntity.java @@ -0,0 +1,31 @@ +package com.smartjam.smartjamapi.entity; + +import java.time.Instant; + +import jakarta.persistence.*; + +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "access_tokens") +@Setter +@Getter +public class AccessTokenEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String jti; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(nullable = false, name = "expires_at") + private Instant expiresAt; + + @Column(nullable = false) + private boolean revoked = false; +} \ No newline at end of file diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/AccessTokenRepository.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/AccessTokenRepository.java new file mode 100644 index 0000000..e4a5bc6 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/AccessTokenRepository.java @@ -0,0 +1,10 @@ +package com.smartjam.smartjamapi.repository; + +import java.util.Optional; + +import com.smartjam.smartjamapi.entity.AccessTokenEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AccessTokenRepository extends JpaRepository { + Optional findByJti(String jti); +} \ No newline at end of file diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtService.java new file mode 100644 index 0000000..dae9ac2 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/security/JwtService.java @@ -0,0 +1,98 @@ +package com.smartjam.smartjamapi.security; + +import java.security.Key; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import javax.crypto.SecretKey; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + @Value("${security.jwt.secret-key}") + private String secretKey; + + @Getter + @Value("${security.jwt.expiration-time}") + private long jwtExpiration; + + private Key getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateRefreshToken() { + byte[] randomBytes = new byte[64]; + new SecureRandom().nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } + + public String generateAccessToken(UserDetailsImpl userDetails, String jti) { + Map claims = new HashMap<>(); + claims.put( + "authorities", + userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList()); + + return Jwts.builder() + .claims(claims) + .subject(userDetails.getEmail()) + .id(jti) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) + .signWith(getSigningKey()) + .compact(); + } + + public String generateJti() { + return UUID.randomUUID().toString(); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public String extractJti(String token) { + return extractClaim(token, Claims::getId); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith((SecretKey) getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public boolean isTokenValid(String token, UserDetailsImpl userDetails) { + final String emailFromToken = extractUsername(token); + return emailFromToken.equals(userDetails.getEmail()) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } +} \ No newline at end of file diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/AuthService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/AuthService.java new file mode 100644 index 0000000..5ecfb94 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/AuthService.java @@ -0,0 +1,167 @@ +package com.smartjam.smartjamapi.service; + +import java.time.Instant; +import java.util.NoSuchElementException; + +import jakarta.transaction.Transactional; + +import com.smartjam.smartjamapi.dto.AuthResponse; +import com.smartjam.smartjamapi.dto.LoginRequest; +import com.smartjam.smartjamapi.dto.RegisterRequest; +import com.smartjam.smartjamapi.dto.TokenDto; +import com.smartjam.smartjamapi.entity.AccessTokenEntity; +import com.smartjam.smartjamapi.entity.RefreshTokenEntity; +import com.smartjam.smartjamapi.entity.UserEntity; +import com.smartjam.smartjamapi.enums.AvailabilityStatus; +import com.smartjam.smartjamapi.enums.Role; +import com.smartjam.smartjamapi.repository.AccessTokenRepository; +import com.smartjam.smartjamapi.repository.RefreshTokenRepository; +import com.smartjam.smartjamapi.repository.UserRepository; +import com.smartjam.smartjamapi.security.JwtService; +import com.smartjam.smartjamapi.security.UserDetailsImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class AuthService { + + private final UserRepository repository; + private final RefreshTokenRepository refreshTokenRepository; + private final AccessTokenRepository accessTokenRepository; + + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + public AuthService( + UserRepository repository, + RefreshTokenRepository refreshTokenRepository, + AccessTokenRepository accessTokenRepository, + PasswordEncoder passwordEncoder, + AuthenticationManager authenticationManager, + JwtService jwtService, + UserDetailsService userDetailsService) { + this.repository = repository; + this.refreshTokenRepository = refreshTokenRepository; + this.accessTokenRepository = accessTokenRepository; + this.passwordEncoder = passwordEncoder; + this.authenticationManager = authenticationManager; + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + public AuthResponse login(LoginRequest request) { + UserEntity userEntity = repository + .findByEmail(request.email()) + .orElseThrow(() -> new NoSuchElementException("Login not found, try register, please")); + if (!passwordEncoder.matches(request.password(), userEntity.getPasswordHash())) { + throw new IllegalStateException("Invalid password"); + } + + UserDetailsImpl userDetails = UserDetailsImpl.build(userEntity); + + String jti = jwtService.generateJti(); + String accessToken = jwtService.generateAccessToken(userDetails, jti); + String refreshToken = jwtService.generateRefreshToken(); + + AccessTokenEntity accessTokenEntity = new AccessTokenEntity(); + accessTokenEntity.setJti(jti); + accessTokenEntity.setUser(userEntity); + accessTokenEntity.setExpiresAt(Instant.now().plusMillis(jwtService.getJwtExpiration())); + accessTokenEntity.setRevoked(false); + + accessTokenRepository.save(accessTokenEntity); + + RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(); + refreshTokenEntity.setToken(refreshToken); + refreshTokenEntity.setUser(userEntity); + refreshTokenEntity.setExpiresAt(Instant.now().plusMillis(jwtService.getJwtExpiration())); + + refreshTokenRepository.save(refreshTokenEntity); + + return new AuthResponse("Logged in successfully", AvailabilityStatus.AVAILABLE, refreshToken, accessToken); + } + + public AuthResponse register(RegisterRequest request) { + boolean exists = repository.findByEmail(request.email()).isPresent(); + + if (exists) { + throw new IllegalStateException("The account exists, try login, please"); + } + + UserEntity userEntity = new UserEntity(); + userEntity.setUsername(request.username()); + userEntity.setEmail(request.email()); + userEntity.setPasswordHash(passwordEncoder.encode(request.password())); + userEntity.setRole(Role.STUDENT); + + repository.save(userEntity); + + UserDetailsImpl userDetails = UserDetailsImpl.build(userEntity); + + String jti = jwtService.generateJti(); + String accessToken = jwtService.generateAccessToken(userDetails, jti); + String refreshToken = jwtService.generateRefreshToken(); + + AccessTokenEntity accessTokenEntity = new AccessTokenEntity(); + accessTokenEntity.setJti(jti); + accessTokenEntity.setUser(userEntity); + accessTokenEntity.setExpiresAt(Instant.now().plusMillis(jwtService.getJwtExpiration())); + accessTokenEntity.setRevoked(false); + + accessTokenRepository.save(accessTokenEntity); + + RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(); + refreshTokenEntity.setToken(refreshToken); + refreshTokenEntity.setUser(userEntity); + refreshTokenEntity.setExpiresAt(Instant.now().plusMillis(jwtService.getJwtExpiration())); + + refreshTokenRepository.save(refreshTokenEntity); + + return new AuthResponse("Register successfully", AvailabilityStatus.AVAILABLE, refreshToken, accessToken); + } + + @Transactional + public AuthResponse getNewToken(TokenDto tokenDto) { + RefreshTokenEntity refreshToken = refreshTokenRepository + .findByToken(tokenDto.refresh_token()) + .orElseThrow(() -> new NoSuchElementException("Token not found, try login, please")); + if (refreshToken.getExpiresAt().isBefore(Instant.now())) { + throw new IllegalStateException("Refresh token expired"); + } + + UserEntity userEntity = repository + .findById(refreshToken.getUser().getId()) + .orElseThrow(() -> new NoSuchElementException("User not found")); + UserDetailsImpl userDetails = UserDetailsImpl.build(userEntity); + + String jti = jwtService.generateJti(); + String accessToken = jwtService.generateAccessToken(userDetails, jti); + String newRefreshToken = jwtService.generateRefreshToken(); + + AccessTokenEntity accessTokenEntity = new AccessTokenEntity(); + accessTokenEntity.setJti(jti); + accessTokenEntity.setUser(userEntity); + accessTokenEntity.setExpiresAt(Instant.now().plusMillis(jwtService.getJwtExpiration())); + accessTokenEntity.setRevoked(false); + + accessTokenRepository.save(accessTokenEntity); + + RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(); + refreshTokenEntity.setToken(newRefreshToken); + refreshTokenEntity.setUser(userEntity); + refreshTokenEntity.setExpiresAt(Instant.now().plusMillis(jwtService.getJwtExpiration())); + + refreshTokenRepository.save(refreshTokenEntity); + + return new AuthResponse( + "Token generate successfully", AvailabilityStatus.AVAILABLE, newRefreshToken, accessToken); + } +} \ No newline at end of file