mirror of https://github.com/halo-dev/halo
feat: add device management mechanism
parent
a93479dc34
commit
8d71fc3966
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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."
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue