Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.theurl.identity.application.contract;

import io.theurl.identity.interfaces.dto.TokenGrantRequestDto;
import io.theurl.identity.interfaces.dto.TokenGrantResponseDto;

import java.util.concurrent.CompletableFuture;

public interface AuthApplicationService {
CompletableFuture<TokenGrantResponseDto> grant(TokenGrantRequestDto request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.theurl.identity.application.event;

import com.neroyun.mediator.Event;
import io.theurl.framework.domain.ApplicationEvent;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.time.LocalDateTime;
import java.util.Map;

@EqualsAndHashCode(callSuper = true)
@Data
public class UserAuthFailureEvent extends ApplicationEvent implements Event {
private String userId;
private String username;
private String grantType;
private LocalDateTime grantTime;
private Map<String, String> data;
private String error;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.theurl.identity.application.event;

import com.neroyun.mediator.Event;
import io.theurl.framework.domain.ApplicationEvent;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.time.LocalDateTime;
import java.util.Map;

@EqualsAndHashCode(callSuper = true)
@Data
public final class UserAuthSuccessEvent extends ApplicationEvent implements Event {
private final String grantType;
private final Long userId;


public UserAuthSuccessEvent(String grantType, Long userId) {
this.grantType = grantType;
this.userId = userId;
}

private String username;
private LocalDateTime grantTime;
private Map<String, String> data;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package io.theurl.identity.application.implement;

import com.neroyun.mediator.Event;
import com.neroyun.mediator.internal.AggregateException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.theurl.framework.application.BaseApplicationService;
import io.theurl.framework.core.ObjectId;
import io.theurl.framework.security.*;
import io.theurl.framework.utility.Cryptography;
import io.theurl.identity.application.contract.AuthApplicationService;
import io.theurl.identity.application.event.UserAuthFailureEvent;
import io.theurl.identity.application.event.UserAuthSuccessEvent;
import io.theurl.identity.external.ExternalAuthProvider;
import io.theurl.identity.external.ExternalAuthResult;
import io.theurl.identity.interfaces.dto.TokenGrantRequestDto;
import io.theurl.identity.interfaces.dto.TokenGrantResponseDto;
import io.theurl.identity.persistence.model.UserAuthInfo;
import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery;
import io.theurl.identity.persistence.query.UserAuthInfoQuery;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.NoResultException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.web.context.annotation.RequestScope;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

@Slf4j
@RequestScope
@Service
public class AuthApplicationServiceImpl extends BaseApplicationService implements AuthApplicationService {

@Value("${jwt.secret}")
private String signingKey;

@Value("${jwt.issuer}")
private String issuer;

private final ApplicationContext applicationContext;

@Autowired
public AuthApplicationServiceImpl(ApplicationContext applicationContext) {
super(applicationContext);
this.applicationContext = applicationContext;
}

@Override
public CompletableFuture<TokenGrantResponseDto> grant(TokenGrantRequestDto request) {
return CompletableFuture.supplyAsync(() -> {
var events = new ArrayList<Event>();
try {

UserAuthInfoQuery query = switch (request.grantType().toLowerCase()) {
case null -> throw new IllegalArgumentException("Grant type is required");
case "" -> throw new IllegalArgumentException("Grant type is required");
case "password" -> {
if (request.username() == null || request.username().isEmpty()) {
throw new IllegalArgumentException("Username is required for username grant type");
}
yield new UserAuthInfoQuery("username", request.username());
}
case "email", "phone" ->
// For email and phone grant types, we should check OTP or other verification methods before querying user info.
checkCodeAsync(request).thenApply(_ -> new UserAuthInfoQuery(request.grantType(), request.username())).join();
case "github", "microsoft", "google", "facebook" ->
authWithExternalAsync(request.grantType(), request.username()).thenApply(userId -> new UserAuthInfoQuery(request.grantType(), userId)).join();
default -> throw new IllegalArgumentException("Unsupported grant type: " + request.grantType());
};

var userInfo = mediator.executeAsync(query).join();

if (userInfo == null) {
throw new CredentialNotFoundException(request.username(), "Invalid username or password.");
}

if (userInfo.getLockedUntil().isAfter(LocalDateTime.now())) {
throw new AccountLockedException(request.username(), "Account is locked until " + userInfo.getLockedUntil());
}

if (request.grantType().equals("password")) {
var passwordHash = Cryptography.AES.encrypt(request.password(), userInfo.getPasswordSalt());
if (!passwordHash.equals(userInfo.getPasswordHash())) {
throw new CredentialIncorrectException("Invalid username or password.");
}
}

events.add(new UserAuthSuccessEvent(request.grantType(), userInfo.getId()));

var jwtId = ObjectId.guid().toString();
var iat = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
var exp = LocalDateTime.now().plusHours(24).toEpochSecond(ZoneOffset.UTC);
var accessToken = generateToken(jwtId, userInfo, iat, exp);

return new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat, userInfo.getUsername(), userInfo.getId());

} catch (Exception e) {
var event = new UserAuthFailureEvent();

handleException(e, ex -> {
switch (ex) {
case AccountLockedException exception:
event.setUserId(exception.getIdentity());
event.setGrantType(request.grantType());
event.setGrantTime(LocalDateTime.now());
event.setError(exception.getLocalizedMessage());
event.setData(Map.of("username", request.username() != null ? request.username() : "", "password", request.password(), "locked", "true"));
break;
case NoResultException ignored:
event.setUsername(request.username());
event.setGrantType(request.grantType());
event.setGrantTime(LocalDateTime.now());
event.setError(ex.getLocalizedMessage());
event.setData(Map.of("username", request.username()));
break;
case EntityNotFoundException ignored:
event.setUsername(request.username());
event.setGrantType(request.grantType());
event.setGrantTime(LocalDateTime.now());
event.setError(ex.getLocalizedMessage());
event.setData(Map.of("username", request.username()));
break;
case AccountNotFoundException ignored:
event.setUsername(request.username());
event.setGrantType(request.grantType());
event.setGrantTime(LocalDateTime.now());
event.setError(ex.getLocalizedMessage());
event.setData(Map.of("username", request.username()));
break;
case CredentialException exception:
event.setUsername(request.username());
event.setGrantType(request.grantType());
event.setGrantTime(LocalDateTime.now());
event.setError(exception.getLocalizedMessage());
event.setData(Map.of("username", request.username(), "password", request.password()));
break;
default:
break;
}
});
log.error("Error while processing request", e);
throw new AggregateException(List.of(e));
} finally {
if (!events.isEmpty()) {
events.parallelStream().forEach(mediator::publishAsync);
}
}
}, mediatorTaskExecutor.getThreadPoolExecutor());
}

CompletableFuture<Void> checkCodeAsync(TokenGrantRequestDto request) {
return mediator.executeAsync(new OnetimePasswordDetailQuery(request.requestId())).thenAccept(otp -> {
if (otp == null) {
throw new CredentialIncorrectException("Invalid verify code.");
}
if (otp.getExpiration().isBefore(LocalDateTime.now())) {
throw new CredentialIncorrectException("Verify code has expired.");
}
if (otp.getChecked() != null) {
throw new CredentialIncorrectException("Verify code has already been used.");
}
if (!otp.getRecipient().equals(request.username())) {
throw new CredentialIncorrectException("Invalid verify code recipient.");
}
if (otp.getCode() == null || !otp.getCode().equals(request.password())) {
throw new CredentialIncorrectException("Invalid verify code.");
}
});
}

CompletableFuture<String> authWithExternalAsync(String grantType, String username) {
var provider = applicationContext.getBean(("external-auth-provider-" + grantType).toLowerCase(), ExternalAuthProvider.class);
// Here we should check if the external auth result is linked to an internal user account, and return the user ID.
// For simplicity, we just return a dummy user ID.
return provider.authenticateAsync(username).thenApply(ExternalAuthResult::getId);
}

private String generateToken(String id, UserAuthInfo user, long issuedAt, long expiresAt) {
Assert.notNull(user, "user cannot be null");
//var signingKey = environment.getProperty("JwtAuthenticationOptions.SigningKey");
Assert.notNull(signingKey, "SigningKey cannot be null");

var builder = Jwts.builder();
builder.subject(String.valueOf(user.getId())).id(id).issuer(issuer).issuedAt(new Date(issuedAt)).expiration(new Date(expiresAt)) // 24小时后过期
.claim("name", user.getUsername());
builder.signWith(Keys.hmacShaKeyFor(signingKey.getBytes()));
return builder.compact();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.theurl.identity.interfaces;

import org.springframework.context.annotation.Configuration;

@Configuration
public class InterfacesApplicationContext {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.theurl.identity.interfaces.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("account")
public class AccountController {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.theurl.identity.interfaces.controller;

import io.theurl.identity.application.contract.AuthApplicationService;
import io.theurl.identity.interfaces.dto.TokenGrantRequestDto;
import io.theurl.identity.interfaces.dto.TokenGrantResponseDto;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("auth")
public class AuthController {
private final AuthApplicationService service;

public AuthController(AuthApplicationService service) {
this.service = service;
}

/**
* Grant an access token based on the provided credentials.
* This endpoint allows clients to obtain an access token by providing valid credentials, such as username and password.
* The server will validate the credentials and, if they are correct, will issue an access token along with a refresh token.
* The access token can be used for authentication and authorization in subsequent requests,
* while the refresh token can be used to obtain a new access token when the current one expires.
*
* @param request The request containing the user's credentials.
* @return A response containing the access token and refresh token.
*/
@PostMapping("token/grant")
public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto request) {
return service.grant(request).join();
}

/**
* Refresh the access token using the provided refresh token.
* This endpoint allows clients to obtain a new access token when the current one expires, without requiring the user to re-authenticate.
* The client must provide a valid refresh token, and if it is valid, a new access token will be issued along with a new refresh token.
*
* @param token The refresh token used to obtain a new access token.
* @return A response containing the new access token and refresh token.
*/
@PostMapping("token/refresh")
public TokenGrantResponseDto refreshToken(@RequestParam String token) {
var request = new TokenGrantRequestDto(token, null, "refresh_token", null);
return service.grant(request).join();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.theurl.identity.interfaces.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("user")
public class UserController {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.theurl.identity.interfaces.dto;

/**
* DTO for token grant request
* @param username the identifier of the user, maybe email, phone number, unique username, etc.
* @param password the password of the user
* @param grantType the type of grant, available values: Username/Email/Phone/Github/Microsoft/Google.
* @param requestId the request ID for the authentication request.
*/
public record TokenGrantRequestDto(String username, String password, String grantType, String requestId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.theurl.identity.interfaces.dto;

/**
* DTO for token grant response
*
* @param accessToken the access token for the user, used for authentication and authorization in subsequent requests.
* @param refreshToken the refresh token for the user, used to obtain a new access token when the current one expires.
* @param tokenType the type of the token, typically "Bearer".
* @param expiresIn the duration in seconds for which the access token is valid.
* @param issueAt the timestamp when the token was issued.
* @param username the username of the authenticated user.
* @param userId the unique identifier of the authenticated user.
*/
public record TokenGrantResponseDto(String accessToken, String refreshToken, String tokenType, long expiresIn,
long issueAt, String username, Long userId) {
}
Loading
Loading