feat: add device management mechanism

pull/6100/head
guqing 2024-06-26 18:52:32 +08:00
parent a93479dc34
commit 8d71fc3966
23 changed files with 1763 additions and 10 deletions

View File

@ -12166,6 +12166,243 @@
]
}
},
"/apis/security.halo.run/v1alpha1/devices": {
"get": {
"description": "List v1alpha1",
"operationId": "listv1alpha1",
"parameters": [
{
"description": "Page number. Default is 0.",
"in": "query",
"name": "page",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"description": "Size number. Default is 0.",
"in": "query",
"name": "size",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"description": "Label selector. e.g.: hidden!\u003dtrue",
"in": "query",
"name": "labelSelector",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"description": "Field selector. e.g.: metadata.name\u003d\u003dhalo",
"in": "query",
"name": "fieldSelector",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.",
"in": "query",
"name": "sort",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/v1alpha1List"
}
}
},
"description": "Response devices"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"post": {
"description": "Create v1alpha1",
"operationId": "createv1alpha1",
"requestBody": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Fresh device"
},
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response devices created just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
}
},
"/apis/security.halo.run/v1alpha1/devices/{name}": {
"delete": {
"description": "Delete v1alpha1",
"operationId": "deletev1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Response device deleted just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"get": {
"description": "Get v1alpha1",
"operationId": "getv1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response single device"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"patch": {
"description": "Patch v1alpha1",
"operationId": "patchv1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/JsonPatch"
}
}
}
},
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response device patched just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"put": {
"description": "Update v1alpha1",
"operationId": "updatev1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Updated device"
},
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response devices updated just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
}
},
"/apis/security.halo.run/v1alpha1/personalaccesstokens": {
"get": {
"description": "List PersonalAccessToken",
@ -14421,6 +14658,55 @@
]
}
},
"/apis/uc.api.security.halo.run/v1alpha1/devices": {
"get": {
"description": "List all user devices",
"operationId": "ListDevices",
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserDevice"
}
}
}
},
"description": "default response"
}
},
"tags": [
"DeviceV1alpha1Uc"
]
}
},
"/apis/uc.api.security.halo.run/v1alpha1/devices/{deviceId}": {
"delete": {
"description": "Revoke a own device",
"operationId": "RevokeDevice",
"parameters": [
{
"description": "Device ID",
"in": "path",
"name": "deviceId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204 NO_CONTENT": {
"description": "default response"
}
},
"tags": [
"DeviceV1alpha1Uc"
]
}
},
"/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens": {
"get": {
"description": "Obtain PAT list.",
@ -16312,6 +16598,81 @@
}
}
},
"Device": {
"required": [
"apiVersion",
"kind",
"metadata",
"spec",
"status"
],
"type": "object",
"properties": {
"apiVersion": {
"type": "string"
},
"kind": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/Metadata"
},
"spec": {
"$ref": "#/components/schemas/DeviceSpec"
},
"status": {
"$ref": "#/components/schemas/DeviceStatus"
}
}
},
"DeviceSpec": {
"required": [
"ipAddress",
"principalName",
"sessionId"
],
"type": "object",
"properties": {
"ipAddress": {
"maxLength": 129,
"type": "string"
},
"lastAccessedTime": {
"type": "string",
"format": "date-time"
},
"lastAuthenticatedTime": {
"type": "string",
"format": "date-time"
},
"principalName": {
"minLength": 1,
"type": "string"
},
"rememberMeSeriesId": {
"type": "string"
},
"sessionId": {
"minLength": 1,
"type": "string"
},
"userAgent": {
"maxLength": 500,
"type": "string"
}
}
},
"DeviceStatus": {
"type": "object",
"properties": {
"browser": {
"type": "string"
},
"os": {
"type": "string"
}
}
},
"EmailConfigValidationRequest": {
"type": "object",
"properties": {
@ -21465,9 +21826,13 @@
}
},
"SubscriptionSubscriber": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
"minLength": 1,
"type": "string"
}
},
@ -22172,6 +22537,25 @@
}
}
},
"UserDevice": {
"required": [
"active",
"currentDevice",
"device"
],
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"currentDevice": {
"type": "boolean"
},
"device": {
"$ref": "#/components/schemas/Device"
}
}
},
"UserEndpoint.ListedUserList": {
"required": [
"first",
@ -22414,6 +22798,65 @@
"type": "string"
}
}
},
"v1alpha1List": {
"required": [
"first",
"hasNext",
"hasPrevious",
"items",
"last",
"page",
"size",
"total",
"totalPages"
],
"type": "object",
"properties": {
"first": {
"type": "boolean",
"description": "Indicates whether current page is the first page."
},
"hasNext": {
"type": "boolean",
"description": "Indicates whether current page has previous page."
},
"hasPrevious": {
"type": "boolean",
"description": "Indicates whether current page has previous page."
},
"items": {
"type": "array",
"description": "A chunk of items.",
"items": {
"$ref": "#/components/schemas/Device"
}
},
"last": {
"type": "boolean",
"description": "Indicates whether current page is the last page."
},
"page": {
"type": "integer",
"description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.",
"format": "int32"
},
"size": {
"type": "integer",
"description": "Size of each page. If not set or equal to 0, it means no pagination.",
"format": "int32"
},
"total": {
"type": "integer",
"description": "Total elements.",
"format": "int64"
},
"totalPages": {
"type": "integer",
"description": "Indicates total pages.",
"format": "int64"
}
}
}
},
"securitySchemes": {

View File

@ -5942,6 +5942,243 @@
]
}
},
"/apis/security.halo.run/v1alpha1/devices": {
"get": {
"description": "List v1alpha1",
"operationId": "listv1alpha1",
"parameters": [
{
"description": "Page number. Default is 0.",
"in": "query",
"name": "page",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"description": "Size number. Default is 0.",
"in": "query",
"name": "size",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"description": "Label selector. e.g.: hidden!\u003dtrue",
"in": "query",
"name": "labelSelector",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"description": "Field selector. e.g.: metadata.name\u003d\u003dhalo",
"in": "query",
"name": "fieldSelector",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.",
"in": "query",
"name": "sort",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/v1alpha1List"
}
}
},
"description": "Response devices"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"post": {
"description": "Create v1alpha1",
"operationId": "createv1alpha1",
"requestBody": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Fresh device"
},
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response devices created just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
}
},
"/apis/security.halo.run/v1alpha1/devices/{name}": {
"delete": {
"description": "Delete v1alpha1",
"operationId": "deletev1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Response device deleted just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"get": {
"description": "Get v1alpha1",
"operationId": "getv1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response single device"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"patch": {
"description": "Patch v1alpha1",
"operationId": "patchv1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/JsonPatch"
}
}
}
},
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response device patched just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
},
"put": {
"description": "Update v1alpha1",
"operationId": "updatev1alpha1",
"parameters": [
{
"description": "Name of device",
"in": "path",
"name": "name",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Updated device"
},
"responses": {
"200": {
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/Device"
}
}
},
"description": "Response devices updated just now"
}
},
"tags": [
"v1alpha1V1alpha1"
]
}
},
"/apis/security.halo.run/v1alpha1/personalaccesstokens": {
"get": {
"description": "List PersonalAccessToken",
@ -8775,6 +9012,81 @@
}
}
},
"Device": {
"required": [
"apiVersion",
"kind",
"metadata",
"spec",
"status"
],
"type": "object",
"properties": {
"apiVersion": {
"type": "string"
},
"kind": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/Metadata"
},
"spec": {
"$ref": "#/components/schemas/DeviceSpec"
},
"status": {
"$ref": "#/components/schemas/DeviceStatus"
}
}
},
"DeviceSpec": {
"required": [
"ipAddress",
"principalName",
"sessionId"
],
"type": "object",
"properties": {
"ipAddress": {
"maxLength": 129,
"type": "string"
},
"lastAccessedTime": {
"type": "string",
"format": "date-time"
},
"lastAuthenticatedTime": {
"type": "string",
"format": "date-time"
},
"principalName": {
"minLength": 1,
"type": "string"
},
"rememberMeSeriesId": {
"type": "string"
},
"sessionId": {
"minLength": 1,
"type": "string"
},
"userAgent": {
"maxLength": 500,
"type": "string"
}
}
},
"DeviceStatus": {
"type": "object",
"properties": {
"browser": {
"type": "string"
},
"os": {
"type": "string"
}
}
},
"Excerpt": {
"required": [
"autoGenerate"
@ -12303,6 +12615,65 @@
"type": "string"
}
}
},
"v1alpha1List": {
"required": [
"first",
"hasNext",
"hasPrevious",
"items",
"last",
"page",
"size",
"total",
"totalPages"
],
"type": "object",
"properties": {
"first": {
"type": "boolean",
"description": "Indicates whether current page is the first page."
},
"hasNext": {
"type": "boolean",
"description": "Indicates whether current page has previous page."
},
"hasPrevious": {
"type": "boolean",
"description": "Indicates whether current page has previous page."
},
"items": {
"type": "array",
"description": "A chunk of items.",
"items": {
"$ref": "#/components/schemas/Device"
}
},
"last": {
"type": "boolean",
"description": "Indicates whether current page is the last page."
},
"page": {
"type": "integer",
"description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.",
"format": "int32"
},
"size": {
"type": "integer",
"description": "Size of each page. If not set or equal to 0, it means no pagination.",
"format": "int32"
},
"total": {
"type": "integer",
"description": "Total elements.",
"format": "int64"
},
"totalPages": {
"type": "integer",
"description": "Indicates total pages.",
"format": "int64"
}
}
}
},
"securitySchemes": {

View File

@ -613,6 +613,55 @@
]
}
},
"/apis/uc.api.security.halo.run/v1alpha1/devices": {
"get": {
"description": "List all user devices",
"operationId": "ListDevices",
"responses": {
"default": {
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserDevice"
}
}
}
},
"description": "default response"
}
},
"tags": [
"DeviceV1alpha1Uc"
]
}
},
"/apis/uc.api.security.halo.run/v1alpha1/devices/{deviceId}": {
"delete": {
"description": "Revoke a own device",
"operationId": "RevokeDevice",
"parameters": [
{
"description": "Device ID",
"in": "path",
"name": "deviceId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204 NO_CONTENT": {
"description": "default response"
}
},
"tags": [
"DeviceV1alpha1Uc"
]
}
},
"/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens": {
"get": {
"description": "Obtain PAT list.",
@ -1013,6 +1062,81 @@
}
}
},
"Device": {
"required": [
"apiVersion",
"kind",
"metadata",
"spec",
"status"
],
"type": "object",
"properties": {
"apiVersion": {
"type": "string"
},
"kind": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/Metadata"
},
"spec": {
"$ref": "#/components/schemas/DeviceSpec"
},
"status": {
"$ref": "#/components/schemas/DeviceStatus"
}
}
},
"DeviceSpec": {
"required": [
"ipAddress",
"principalName",
"sessionId"
],
"type": "object",
"properties": {
"ipAddress": {
"maxLength": 129,
"type": "string"
},
"lastAccessedTime": {
"type": "string",
"format": "date-time"
},
"lastAuthenticatedTime": {
"type": "string",
"format": "date-time"
},
"principalName": {
"minLength": 1,
"type": "string"
},
"rememberMeSeriesId": {
"type": "string"
},
"sessionId": {
"minLength": 1,
"type": "string"
},
"userAgent": {
"maxLength": 500,
"type": "string"
}
}
},
"DeviceStatus": {
"type": "object",
"properties": {
"browser": {
"type": "string"
},
"os": {
"type": "string"
}
}
},
"Excerpt": {
"required": [
"autoGenerate"
@ -1812,6 +1936,25 @@
"type": "boolean"
}
}
},
"UserDevice": {
"required": [
"active",
"currentDevice",
"device"
],
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"currentDevice": {
"type": "boolean"
},
"device": {
"$ref": "#/components/schemas/Device"
}
}
}
},
"securitySchemes": {

View File

@ -19,6 +19,7 @@ import run.halo.app.content.Stats;
import run.halo.app.core.extension.AnnotationSetting;
import run.halo.app.core.extension.AuthProvider;
import run.halo.app.core.extension.Counter;
import run.halo.app.core.extension.Device;
import run.halo.app.core.extension.Menu;
import run.halo.app.core.extension.MenuItem;
import run.halo.app.core.extension.Plugin;
@ -448,6 +449,14 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
)
);
});
schemeManager.register(Device.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.principalName")
.setIndexFunc(simpleAttribute(Device.class,
device -> device.getSpec().getPrincipalName())
)
);
});
// migration.halo.run
schemeManager.register(Backup.class);

View File

@ -21,6 +21,7 @@ import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
import run.halo.app.security.device.DeviceService;
@Component
public class LoginSecurityConfigurer implements SecurityConfigurer {
@ -43,6 +44,7 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
private final RateLimiterRegistry rateLimiterRegistry;
private final RememberMeServices rememberMeServices;
private final DeviceService deviceService;
public LoginSecurityConfigurer(ObservationRegistry observationRegistry,
ReactiveUserDetailsService userDetailsService,
@ -50,7 +52,8 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
ExtensionGetter extensionGetter, ServerResponse.Context context,
MessageSource messageSource, RateLimiterRegistry rateLimiterRegistry,
RememberMeServices rememberMeServices) {
RememberMeServices rememberMeServices,
DeviceService deviceService) {
this.observationRegistry = observationRegistry;
this.userDetailsService = userDetailsService;
this.passwordService = passwordService;
@ -62,13 +65,15 @@ public class LoginSecurityConfigurer implements SecurityConfigurer {
this.messageSource = messageSource;
this.rateLimiterRegistry = rateLimiterRegistry;
this.rememberMeServices = rememberMeServices;
this.deviceService = deviceService;
}
@Override
public void configure(ServerHttpSecurity http) {
var filter = new AuthenticationWebFilter(authenticationManager());
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
var handler = new UsernamePasswordHandler(context, messageSource, rememberMeServices);
var handler =
new UsernamePasswordHandler(context, messageSource, rememberMeServices, deviceService);
var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry);
filter.setRequiresAuthenticationMatcher(requiresMatcher);
filter.setAuthenticationFailureHandler(handler);

View File

@ -22,6 +22,7 @@ import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
import run.halo.app.security.device.DeviceService;
@Slf4j
public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandler,
@ -33,6 +34,8 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
private final RememberMeServices rememberMeServices;
private final DeviceService deviceService;
private final ServerAuthenticationFailureHandler defaultFailureHandler =
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
@ -40,10 +43,11 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
new RedirectServerAuthenticationSuccessHandler("/console/");
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource,
RememberMeServices rememberMeServices) {
RememberMeServices rememberMeServices, DeviceService deviceService) {
this.context = context;
this.messageSource = messageSource;
this.rememberMeServices = rememberMeServices;
this.deviceService = deviceService;
}
@Override
@ -68,6 +72,7 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
if (authentication instanceof TwoFactorAuthentication) {
// continue filtering for authorization
return rememberMeServices.loginSuccess(webFilterExchange.getExchange(), authentication)
.then(deviceService.loginSuccess(webFilterExchange.getExchange(), authentication))
.then(webFilterExchange.getChain().filter(webFilterExchange.getExchange()));
}
@ -85,6 +90,7 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
var exchange = webFilterExchange.getExchange();
return rememberMeServices.loginSuccess(exchange, authentication)
.then(deviceService.loginSuccess(exchange, authentication))
.then(xhrMatcher.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.switchIfEmpty(Mono.defer(

View File

@ -1,6 +1,7 @@
package run.halo.app.security.authentication.rememberme;
import java.time.Instant;
import org.springframework.lang.NonNull;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;
import reactor.core.publisher.Mono;
@ -12,4 +13,6 @@ public interface PersistentRememberMeTokenRepository {
Mono<PersistentRememberMeToken> getTokenForSeries(String seriesId);
Mono<Void> removeUserTokens(String username);
Mono<Void> removeToken(@NonNull String series);
}

View File

@ -1,12 +1,15 @@
package run.halo.app.security.authentication.rememberme;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.lang.NonNull;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@ -81,9 +84,19 @@ public class PersistentRememberMeTokenRepositoryImpl
return paginatedOperator.deleteInitialBatch(RememberMeToken.class, listOptions).then();
}
@Override
public Mono<Void> removeToken(@NonNull String series) {
return getTokenExtensionForSeries(series)
.flatMap(client::delete)
.then();
}
private Mono<RememberMeToken> getTokenExtensionForSeries(String seriesId) {
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(equal("spec.series", seriesId)));
var listOptions = ListOptions.builder()
.fieldQuery(and(equal("spec.series", seriesId),
isNull("metadata.deletionTimestamp")
))
.build();
return client.listBy(RememberMeToken.class, listOptions, PageRequestImpl.ofSize(1))
.flatMap(result -> Mono.justOrEmpty(ListResult.first(result)));
}

View File

@ -51,6 +51,8 @@ import reactor.core.publisher.Mono;
public class PersistentTokenBasedRememberMeServices extends TokenBasedRememberMeServices
implements RememberMeServices {
public static final String REMEMBER_ME_SERIES_REQUEST_NAME = "remember-me-series";
public static final int DEFAULT_SERIES_LENGTH = 16;
public static final int DEFAULT_TOKEN_LENGTH = 16;
@ -167,6 +169,7 @@ public class PersistentTokenBasedRememberMeServices extends TokenBasedRememberMe
private void addCookie(PersistentRememberMeToken token, ServerWebExchange exchange) {
setCookie(new String[] {token.getSeries(), token.getTokenValue()}, exchange);
exchange.getAttributes().put(REMEMBER_ME_SERIES_REQUEST_NAME, token.getSeries());
}
protected String generateSeriesData() {

View File

@ -10,6 +10,7 @@ import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFilter;
import run.halo.app.security.device.DeviceService;
@Component
public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
@ -24,25 +25,28 @@ public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
private final RememberMeServices rememberMeServices;
private final DeviceService deviceService;
public TwoFactorAuthSecurityConfigurer(
ServerSecurityContextRepository securityContextRepository,
TotpAuthService totpAuthService,
ServerResponse.Context context,
MessageSource messageSource,
RememberMeServices rememberMeServices
RememberMeServices rememberMeServices,
DeviceService deviceService
) {
this.securityContextRepository = securityContextRepository;
this.totpAuthService = totpAuthService;
this.context = context;
this.messageSource = messageSource;
this.rememberMeServices = rememberMeServices;
this.deviceService = deviceService;
}
@Override
public void configure(ServerHttpSecurity http) {
var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
context, messageSource, rememberMeServices);
context, messageSource, rememberMeServices, deviceService);
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
}
}

View File

@ -22,6 +22,7 @@ import run.halo.app.security.HaloUserDetails;
import run.halo.app.security.authentication.login.UsernamePasswordHandler;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
import run.halo.app.security.device.DeviceService;
@Slf4j
public class TotpAuthenticationFilter extends AuthenticationWebFilter {
@ -31,7 +32,8 @@ public class TotpAuthenticationFilter extends AuthenticationWebFilter {
TotpAuthService totpAuthService,
ServerResponse.Context context,
MessageSource messageSource,
RememberMeServices rememberMeServices
RememberMeServices rememberMeServices,
DeviceService deviceService
) {
super(new TwoFactorAuthManager(totpAuthService));
@ -39,7 +41,8 @@ public class TotpAuthenticationFilter extends AuthenticationWebFilter {
setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
var handler = new UsernamePasswordHandler(context, messageSource, rememberMeServices);
var handler =
new UsernamePasswordHandler(context, messageSource, rememberMeServices, deviceService);
setAuthenticationSuccessHandler(handler);
setAuthenticationFailureHandler(handler);
}

View File

@ -0,0 +1,19 @@
package run.halo.app.security.device;
import java.time.Duration;
import org.springframework.http.HttpCookie;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerWebExchange;
public interface DeviceCookieResolver {
@Nullable
HttpCookie resolveCookie(ServerWebExchange exchange);
void setCookie(ServerWebExchange exchange, String value);
void expireCookie(ServerWebExchange exchange);
String getCookieName();
Duration getCookieMaxAge();
}

View File

@ -0,0 +1,47 @@
package run.halo.app.security.device;
import java.time.Duration;
import lombok.Getter;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
@Getter
@Component
public class DeviceCookieResolverImpl implements DeviceCookieResolver {
public static final String DEVICE_COOKIE_KEY = "device_id";
private final String cookieName = DEVICE_COOKIE_KEY;
private final Duration cookieMaxAge = Duration.ofDays(100);
@Override
public HttpCookie resolveCookie(ServerWebExchange exchange) {
return exchange.getRequest().getCookies().getFirst(getCookieName());
}
@Override
public void setCookie(ServerWebExchange exchange, String value) {
Assert.notNull(value, "'value' is required");
exchange.getResponse().getCookies()
.set(getCookieName(), initCookie(exchange, value).build());
}
@Override
public void expireCookie(ServerWebExchange exchange) {
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
exchange.getResponse().getCookies().set(this.cookieName, cookie);
}
private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
String value) {
return ResponseCookie.from(this.cookieName, value)
.path(exchange.getRequest().getPath().contextPath().value() + "/")
.maxAge(getCookieMaxAge())
.httpOnly(true)
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
.sameSite("Lax");
}
}

View File

@ -0,0 +1,163 @@
package run.halo.app.security.device;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import java.security.Principal;
import java.util.Comparator;
import java.util.Map;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.session.Session;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Device;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.security.session.ReactiveIndexedSessionRepository;
/**
* Device endpoint for user profile,every user can only manage their own devices.
*
* @author guqing
* @since 2.17.0
*/
@Component
@RequiredArgsConstructor
public class DeviceEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
private final ReactiveIndexedSessionRepository<?> sessionRepository;
private final DeviceService deviceService;
@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "DeviceV1alpha1Uc";
return SpringdocRouteBuilder.route()
.GET("devices", this::listDevices,
builder -> builder.operationId("ListDevices")
.description("List all user devices")
.tag(tag)
.response(responseBuilder().implementationArray(DeviceDto.class))
)
.DELETE("devices/{deviceId}", this::revokeDevice, builder -> builder
.operationId("RevokeDevice")
.description("Revoke a own device")
.tag(tag)
.parameter(parameterBuilder()
.in(ParameterIn.PATH)
.name("deviceId")
.description("Device ID")
.required(true)
)
.response(responseBuilder()
.responseCode(String.valueOf(HttpStatus.NO_CONTENT))
)
)
.build();
}
private Mono<ServerResponse> revokeDevice(ServerRequest request) {
final var deviceId = request.pathVariable("deviceId");
return principalName()
.flatMap(principalName -> deviceService.revoke(principalName, deviceId))
.then(ServerResponse.noContent().build());
}
private Mono<ServerResponse> listDevices(ServerRequest request) {
return getRequestContext(request)
.flatMapMany(context -> {
var listOptions = new ListOptions();
var query = equal("spec.principalName", context.username());
listOptions.setFieldSelector(FieldSelector.of(query));
return client.listAll(Device.class, listOptions,
Sort.by("metadata.creationTimestamp"))
.map(device -> {
var sessionId = device.getSpec().getSessionId();
var session = context.sessionMap().get(sessionId);
if (session != null) {
device.getSpec().setLastAccessedTime(session.getLastAccessedTime());
}
return new DeviceDto()
.setDevice(device)
.setCurrentDevice(context.sessionId().equals(sessionId))
.setActive(session != null && !session.isExpired());
})
.sort(deviceDtoComparator());
})
.collectList()
.flatMap(deviceDto -> ServerResponse.ok().bodyValue(deviceDto));
}
Comparator<DeviceDto> deviceDtoComparator() {
return Comparator.comparing(DeviceDto::isCurrentDevice)
.thenComparing(DeviceDto::isActive)
.thenComparing(DeviceDto::getDevice, Comparator.comparing(device -> {
var accessedTime = device.getSpec().getLastAccessedTime();
return accessedTime == null ? device.getMetadata().getCreationTimestamp()
: accessedTime;
}))
.reversed();
}
private Mono<RequestContext> getRequestContext(ServerRequest request) {
return principalName()
.flatMap(principalName -> {
var builder = RequestContext.builder()
.sessionMap(Map.of())
.username(principalName);
var sessionMapMono = sessionRepository.findByPrincipalName(principalName)
.doOnNext(builder::sessionMap);
var sessionMono = request.exchange().getSession()
.doOnNext(session -> builder.sessionId(session.getId()));
return Mono.when(sessionMapMono, sessionMono)
.then(Mono.fromSupplier(builder::build));
});
}
@Builder
record RequestContext(String username, String sessionId,
Map<String, ? extends Session> sessionMap) {
}
Mono<String> principalName() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName);
}
@Data
@Accessors(chain = true)
@Schema(name = "UserDevice")
static class DeviceDto {
@Schema(requiredMode = REQUIRED)
private Device device;
@Schema(requiredMode = REQUIRED)
boolean currentDevice;
@Schema(requiredMode = REQUIRED)
boolean active;
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("uc.api.security.halo.run/v1alpha1");
}
}

View File

@ -0,0 +1,72 @@
package run.halo.app.security.device;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.isDeleted;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.session.ReactiveSessionRepository;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.Device;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.router.selector.FieldSelector;
@Component
@RequiredArgsConstructor
public class DeviceReconciler implements Reconciler<Reconciler.Request> {
private static final int MAX_DEVICES = 10;
static final String FINALIZER_NAME = "device-protection";
private final ReactiveSessionRepository<?> sessionRepository;
private final ExtensionClient client;
@Override
public Result reconcile(Request request) {
client.fetch(Device.class, request.name())
.ifPresent(device -> {
if (isDeleted(device)) {
if (removeFinalizers(device.getMetadata(), Set.of(FINALIZER_NAME))) {
sessionRepository.deleteById(device.getSpec().getSessionId())
.block();
client.update(device);
}
return;
}
if (addFinalizers(device.getMetadata(), Set.of(FINALIZER_NAME))) {
client.update(device);
}
revokeInactiveDevices(device.getSpec().getPrincipalName());
});
return Result.doNotRetry();
}
private void revokeInactiveDevices(String principalName) {
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(
equal("spec.principalName", principalName))
);
client.listAll(Device.class, listOptions,
Sort.by("metadata.creationTimestamp").descending())
.stream()
.skip(MAX_DEVICES)
.filter(device -> sessionRepository.findById(device.getSpec().getSessionId())
.blockOptional()
.isEmpty()
)
.forEach(client::delete);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Device())
.syncAllOnStart(false)
.build();
}
}

View File

@ -0,0 +1,257 @@
package run.halo.app.security.device;
import static run.halo.app.infra.utils.IpAddressUtils.getClientIp;
import static run.halo.app.security.authentication.rememberme.PersistentTokenBasedRememberMeServices.REMEMBER_ME_SERIES_REQUEST_NAME;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.session.ReactiveSessionRepository;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Device;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.security.authentication.rememberme.PersistentRememberMeTokenRepository;
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceServiceImpl implements DeviceService {
private final ReactiveExtensionClient client;
private final DeviceCookieResolver deviceCookieResolver;
private final ReactiveSessionRepository<?> sessionRepository;
private final ApplicationEventPublisher eventPublisher;
private final PersistentRememberMeTokenRepository rememberMeTokenRepository;
@Override
public Mono<Void> loginSuccess(ServerWebExchange exchange, Authentication authentication) {
return updateExistingDevice(exchange)
.switchIfEmpty(createDevice(exchange, authentication)
.flatMap(client::create)
.doOnNext(device -> {
deviceCookieResolver.setCookie(exchange, device.getMetadata().getName());
eventPublisher.publishEvent(new NewDeviceLoginEvent(this, device));
})
)
.then();
}
@Override
public Mono<Void> changeSessionId(ServerWebExchange exchange) {
var deviceIdCookie = deviceCookieResolver.resolveCookie(exchange);
if (deviceIdCookie == null) {
return Mono.empty();
}
return ReactiveSecurityContextHolder.getContext()
.map(context -> context.getAuthentication().getName())
.flatMap(username -> {
var deviceId = deviceIdCookie.getValue();
return updateWithRetry(deviceId, device -> {
if (!device.getSpec().getPrincipalName().equals(username)) {
return Mono.empty();
}
var oldSessionId = device.getSpec().getSessionId();
return exchange.getSession()
.filter(session -> !session.getId().equals(oldSessionId))
.flatMap(session -> {
device.getSpec().setSessionId(session.getId());
device.getSpec().setLastAccessedTime(session.getLastAccessTime());
return sessionRepository.deleteById(oldSessionId);
})
.thenReturn(device);
}).then();
});
}
private Mono<Device> updateWithRetry(String deviceId,
Function<Device, Mono<Device>> updateFunction) {
return Mono.defer(() -> client.fetch(Device.class, deviceId)
.flatMap(updateFunction)
.flatMap(client::update)
)
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance));
}
private Mono<Device> updateExistingDevice(ServerWebExchange exchange) {
var deviceIdCookie = deviceCookieResolver.resolveCookie(exchange);
if (deviceIdCookie == null) {
return Mono.empty();
}
return updateWithRetry(deviceIdCookie.getValue(), (Device existingDevice) -> {
var sessionId = existingDevice.getSpec().getSessionId();
return exchange.getSession()
.flatMap(session -> {
var userAgent =
exchange.getRequest().getHeaders().getFirst(HttpHeaders.USER_AGENT);
var deviceUa = existingDevice.getSpec().getUserAgent();
var ipAddr = existingDevice.getSpec().getIpAddress();
var clientIp = getClientIp(exchange.getRequest());
if (!StringUtils.equals(deviceUa, userAgent)
|| !StringUtils.equals(clientIp, ipAddr)) {
// User agent changed, create a new device
return Mono.empty();
}
return Mono.just(session);
})
.flatMap(session -> {
if (session.getId().equals(sessionId)) {
return Mono.just(session);
}
return sessionRepository.deleteById(sessionId).thenReturn(session);
})
.map(session -> {
existingDevice.getSpec().setSessionId(session.getId());
existingDevice.getSpec().setLastAccessedTime(session.getLastAccessTime());
existingDevice.getSpec().setLastAuthenticatedTime(Instant.now());
return existingDevice;
})
.flatMap(this::removeRememberMeToken);
});
}
@Override
public Mono<Void> revoke(String principalName, String deviceId) {
return client.fetch(Device.class, deviceId)
.filter(device -> device.getSpec().getPrincipalName().equals(principalName))
.flatMap(this::removeRememberMeToken)
.flatMap(client::delete)
.flatMap(revoked -> sessionRepository.deleteById(revoked.getSpec().getSessionId()));
}
private Mono<Device> removeRememberMeToken(Device device) {
var seriesId = device.getSpec().getRememberMeSeriesId();
if (StringUtils.isBlank(seriesId)) {
return Mono.just(device);
}
log.debug("Removing remember-me token for seriesId: {}", seriesId);
return rememberMeTokenRepository.removeToken(seriesId)
.thenReturn(device);
}
Mono<Device> createDevice(ServerWebExchange exchange, Authentication authentication) {
Assert.notNull(authentication, "Authentication must not be null.");
return Mono.fromSupplier(
() -> {
var device = new Device();
device.setMetadata(new Metadata());
device.getMetadata().setName(generateDeviceId());
var userAgent =
exchange.getRequest().getHeaders().getFirst(HttpHeaders.USER_AGENT);
var deviceInfo = DeviceInfo.parse(userAgent);
device.setSpec(new Device.Spec()
.setUserAgent(userAgent)
.setPrincipalName(authentication.getName())
.setLastAuthenticatedTime(Instant.now())
.setIpAddress(getClientIp(exchange.getRequest()))
.setRememberMeSeriesId(
exchange.getAttribute(REMEMBER_ME_SERIES_REQUEST_NAME))
);
device.getStatus()
.setOs(deviceInfo.os())
.setBrowser(deviceInfo.browser());
return device;
})
.flatMap(device -> exchange.getSession()
.doOnNext(session -> {
device.getSpec().setSessionId(session.getId());
device.getSpec().setLastAccessedTime(session.getLastAccessTime());
})
.thenReturn(device)
);
}
String generateDeviceId() {
return UUID.randomUUID().toString()
.replace("-", "").toLowerCase();
}
record DeviceInfo(String browser, String os) {
static final String UNKNOWN = "Unknown";
static final Pattern BROWSER_REGEX =
Pattern.compile("(MSIE|Trident|Edge|Edg|OPR|Opera|Chrome|Safari|Firefox"
+ "|FxiOS|SamsungBrowser|UCBrowser|UCWEB|CriOS|Silk|Raven\\|Raven\\|)",
Pattern.CASE_INSENSITIVE);
static final Pattern BROWSER_VERSION_REGEX =
Pattern.compile("(?:version/|chrome/|firefox/|safari/|msie "
+ "|rv:|opr/|edg/|ucbrowser/|samsungbrowser/|crios/|silk/)(\\d+\\.\\d+)",
Pattern.CASE_INSENSITIVE);
static final Pattern OS_REGEX =
Pattern.compile("(Windows NT|Mac OS X|Android|Linux|iPhone|iPad|Windows Phone)");
static final Pattern[] osRegexes = {
Pattern.compile("Windows NT (\\d+\\.\\d+)"),
Pattern.compile("Mac OS X (\\d+[\\._]\\d+([\\._]\\d+)?)"),
Pattern.compile("iPhone OS (\\d+_\\d+(_\\d+)?)"),
Pattern.compile("Android (\\d+\\.\\d+(\\.\\d+)?)")
};
public static DeviceInfo parse(String userAgent) {
return new DeviceInfo(concat(parseBrowser(userAgent).name(),
parseBrowser(userAgent).version()),
concat(parseOperatingSystem(userAgent).name(),
parseOperatingSystem(userAgent).version())
);
}
private static Pair parseBrowser(String userAgent) {
Matcher matcher = BROWSER_REGEX.matcher(userAgent);
if (matcher.find()) {
String browserName = matcher.group(1);
matcher = BROWSER_VERSION_REGEX.matcher(userAgent);
if (matcher.find()) {
String browserVersion = matcher.group(1);
return new Pair(browserName, browserVersion);
} else {
return new Pair(browserName, null);
}
} else {
return new Pair(UNKNOWN, null);
}
}
record Pair(String name, String version) {
}
private static Pair parseOperatingSystem(String userAgent) {
Matcher matcher = OS_REGEX.matcher(userAgent);
var osName = UNKNOWN;
if (matcher.find()) {
osName = matcher.group(1);
}
var osVersion = parseOsVersion(userAgent);
return new Pair(osName, osVersion);
}
private static String parseOsVersion(String userAgent) {
for (Pattern pattern : osRegexes) {
Matcher matcher = pattern.matcher(userAgent);
if (matcher.find()) {
return matcher.group(1).replace("_", ".");
}
}
return "";
}
private static String concat(String name, String version) {
return StringUtils.isBlank(version) ? name : name + " " + version;
}
}
}

View File

@ -0,0 +1,23 @@
package run.halo.app.security.device;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
@Component
@RequiredArgsConstructor
public class DeviceSessionFilter implements WebFilter {
private final DeviceService deviceService;
@Override
@NonNull
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
return exchange.getSession()
.flatMap(session -> deviceService.changeSessionId(exchange))
.then(chain.filter(exchange));
}
}

View File

@ -0,0 +1,15 @@
package run.halo.app.security.device;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import run.halo.app.core.extension.Device;
@Getter
public class NewDeviceLoginEvent extends ApplicationEvent {
private final Device device;
public NewDeviceLoginEvent(Object source, Device device) {
super(source);
this.device = device;
}
}

View File

@ -0,0 +1,69 @@
package run.halo.app.security.device;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Device;
import run.halo.app.core.extension.notification.Reason;
import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.notification.NotificationCenter;
import run.halo.app.notification.NotificationReasonEmitter;
import run.halo.app.notification.ReasonAttributes;
import run.halo.app.notification.UserIdentity;
/**
* <p>Sends a notification when a new device login,It listens for {@link NewDeviceLoginEvent}
* asynchronously.</p>
*
* @author guqing
* @since 2.17.0
*/
@Component
@RequiredArgsConstructor
public class NewDeviceLoginListener implements ApplicationListener<NewDeviceLoginEvent> {
static final String REASON_TYPE = "new-device-login";
private final NotificationCenter notificationCenter;
private final NotificationReasonEmitter notificationReasonEmitter;
@Async
@Override
public void onApplicationEvent(@NonNull NewDeviceLoginEvent event) {
subscribeForNewDeviceLoginReason(event.getDevice())
.then(sendNewDeviceNotification(event.getDevice()))
.block();
}
Mono<Void> sendNewDeviceNotification(Device device) {
return notificationReasonEmitter.emit(REASON_TYPE, builder -> {
var attributes = new ReasonAttributes();
attributes.put("principalName", device.getSpec().getPrincipalName());
attributes.put("os", device.getStatus().getOs());
attributes.put("browser", device.getStatus().getBrowser());
attributes.put("ipAddress", device.getSpec().getIpAddress());
attributes.put("loginTime", device.getSpec().getLastAuthenticatedTime());
builder.attributes(attributes)
.author(UserIdentity.of(device.getSpec().getPrincipalName()))
.subject(Reason.Subject.builder()
.apiVersion(Device.GROUP + "/" + Device.VERSION)
.kind(Device.KIND)
.name(device.getMetadata().getName())
.title("在新设备上登录")
.build());
});
}
Mono<Void> subscribeForNewDeviceLoginReason(Device device) {
var principalName = device.getSpec().getPrincipalName();
var subscriber = new Subscription.Subscriber();
subscriber.setName(principalName);
var reason = new Subscription.InterestReason();
reason.setReasonType(REASON_TYPE);
reason.setExpression("props.principalName == '%s'".formatted(principalName));
return notificationCenter.subscribe(subscriber, reason)
.then();
}
}

View File

@ -153,3 +153,37 @@ spec:
<p>如果您没有请求重置密码,请忽略此电子邮件。</p>
</div>
</div>
---
apiVersion: notification.halo.run/v1alpha1
kind: NotificationTemplate
metadata:
name: template-new-device-login
spec:
reasonSelector:
reasonType: new-device-login
language: default
template:
title: "你的 [(${site.title})] 账号被用于在 [(${os})] 上登录"
rawBody: |
[(${subscriber.displayName})] 你好:
你的 [(${site.title})] 账号被用于在 [(${os})] 的 [(${browser})] 上登录。
时间:[(${loginTime})]
IP 地址:[(${ipAddress})]
如果你知悉上述信息,请忽略此电子邮件。
如果你最近没有使用你的 Halo 账号登录并相信有人可能访问了你的账户,请尽快重设你的密码。
htmlBody: |
<div class="notification-content">
<div class="head">
<p class="honorific" th:text="|${subscriber.displayName} 你好:|"></p>
</div>
<div class="body">
<p th:text="|你的 ${site.title} 账号被用于在 ${os} 的 ${browser} 上登录:|"></p>
<div class="device-info">
<p th:text="|时间: ${loginTime}。|"></p>
<p th:text="|IP 地址: ${ipAddress}。|"></p>
</div>
<p>如果你知悉上述信息,请忽略此电子邮件。</p>
<p th:text="|如果你最近没有使用你的 ${site.title} 账号登录并相信有人可能访问了你的账户,请尽快重设你的密码。|"></p>
</div>
</div>

View File

@ -205,3 +205,27 @@ spec:
- name: expirationAtMinutes
type: string
description: "The expiration minutes of the reset link, such as 30 minutes."
---
apiVersion: notification.halo.run/v1alpha1
kind: ReasonType
metadata:
name: new-device-login
spec:
displayName: "新设备登录"
description: "当你的账户在新设备上登录时,你会收到一条通知,告诉你有新设备登录了你的账户。"
properties:
- name: os
type: string
description: "The operating system of the device."
- name: browser
type: string
description: "The browser of the device."
- name: ipAddress
type: string
description: "The IP address of the device."
- name: loginTime
type: string
description: "The login time of the device."
- name: principalName
type: string
description: "The principal name of the device."

View File

@ -129,6 +129,9 @@ rules:
- apiGroups: [ "api.security.halo.run" ]
resources: [ "authentications", "authentications/totp", "authentications/settings" ]
verbs: [ "*" ]
- apiGroups: [ "uc.api.security.halo.run" ]
resources: [ "devices" ]
verbs: [ "get", "list", "delete" ]
---
apiVersion: v1alpha1
kind: Role

View File

@ -0,0 +1,24 @@
package run.halo.app.security.device;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link DeviceServiceImpl}.
*
* @author guqing
* @since 2.17.0
*/
class DeviceServiceImplTest {
@Test
void deviceInfoParseTest() {
var info = DeviceServiceImpl.DeviceInfo.parse(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like "
+ "Gecko) Chrome/126.0.0.0 Safari/537.36");
assertThat(info.os()).isEqualTo("Mac OS X 10.15.7");
assertThat(info.browser()).isEqualTo("Chrome 126.0");
}
}