mirror of
https://gitee.com/dromara/MaxKey.git
synced 2026-06-10 03:07:33 +08:00
feat: 实现 passkey 登录注册功能前端支持
- 添加 passkey 组件和相关路由配置 - 修复 build.gradle 添加 -parameters 编译参数 - 更新前端依赖和国际化配置 - 优化登录界面支持 passkey 认证
This commit is contained in:
@@ -54,6 +54,7 @@ allprojects {
|
||||
targetCompatibility = 17
|
||||
compileJava.options.encoding = 'UTF-8'
|
||||
|
||||
compileJava.options.compilerArgs += ['-parameters']
|
||||
eclipse {
|
||||
/*设置工程字符集*/
|
||||
jdt {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maxkey",
|
||||
"version": "4.1.x",
|
||||
"version": "4.1.0",
|
||||
"description": "Leading-Edge IAM Identity and Access Management",
|
||||
"author": "MaxKey <support@maxsso.net>",
|
||||
"repository": {
|
||||
|
||||
@@ -27,8 +27,14 @@ import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
|
||||
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
|
||||
import { NzStepsModule } from 'ng-zorro-antd/steps';
|
||||
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzListModule } from 'ng-zorro-antd/list';
|
||||
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
|
||||
|
||||
import { AccoutsComponent } from './accouts/accouts.component';
|
||||
import { MfaComponent } from './mfa/mfa.component';
|
||||
|
||||
import { PasskeyComponent } from './passkey/passkey.component';
|
||||
import { PasswordComponent } from './password/password.component';
|
||||
import { ProfileComponent } from './profile/profile.component';
|
||||
import { SocialsAssociateComponent } from './socials-associate/socials-associate.component';
|
||||
@@ -44,6 +50,10 @@ const routes: Routes = [
|
||||
path: 'password',
|
||||
component: PasswordComponent
|
||||
},
|
||||
{
|
||||
path: 'passkey',
|
||||
component: PasskeyComponent
|
||||
},
|
||||
{
|
||||
path: 'socialsassociate',
|
||||
component: SocialsAssociateComponent
|
||||
@@ -69,9 +79,10 @@ const COMPONENTS = [ProfileComponent];
|
||||
PasswordComponent,
|
||||
ProfileComponent,
|
||||
AccoutsComponent,
|
||||
MfaComponent
|
||||
MfaComponent,
|
||||
PasskeyComponent
|
||||
],
|
||||
imports: [SharedModule, CommonModule, RouterModule.forChild(routes)],
|
||||
imports: [SharedModule, CommonModule, RouterModule.forChild(routes), NzEmptyModule, NzListModule, NzPopconfirmModule],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ConfigModule {}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<nz-card nzTitle="Passkey 管理">
|
||||
|
||||
<div nz-row [nzGutter]="24">
|
||||
<div nz-col [nzSpan]="24">
|
||||
<div class="mb-md">
|
||||
<p class="text-grey">Passkey 是一种更安全、更便捷的登录方式,使用您的设备生物识别或 PIN 码进行身份验证。</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-lg">
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
nzSize="large"
|
||||
[nzLoading]="loading"
|
||||
(click)="registerPasskey()">
|
||||
<i nz-icon nzType="plus-circle" nzTheme="outline"></i>
|
||||
注册新的 Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nz-divider nzText="已注册的 Passkey"></nz-divider>
|
||||
|
||||
|
||||
|
||||
<nz-table #basicTable [nzData]="passkeyList" [nzShowPagination]="false">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>凭证信息</th>
|
||||
<th>签名统计</th>
|
||||
<th>创建时间</th>
|
||||
<th>最近访问时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of passkeyList; let i = index">
|
||||
<td>
|
||||
<div class="credential-info">
|
||||
<div class="credential-id">{{ item.credentialId || item.id }}</div>
|
||||
<div class="device-type">{{ item.deviceType === 'platform' ? '平台认证器' : '跨平台认证器' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ item.signatureCount || 0 }}</td>
|
||||
<td>{{ item.createdDate | date:'yyyy-MM-dd HH:mm:ss' }}</td>
|
||||
<td>{{ item.lastUsedDate | date:'yyyy-MM-dd HH:mm:ss' }}</td>
|
||||
<td>
|
||||
<button nz-button nzType="link" nzDanger nzSize="small" (click)="confirmDeletePasskey(item.credentialId || item.id)">
|
||||
删除 passkey
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</nz-table>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nz-card>
|
||||
@@ -0,0 +1,112 @@
|
||||
.text-grey {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mb-lg {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mt-lg {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.py-lg {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nz-list-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
nz-list-item-meta-title h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
nz-list-item-meta-description p {
|
||||
margin: 4px 0;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.passkey-info {
|
||||
p {
|
||||
margin: 6px 0;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #d63384;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nz-list-item-meta-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
nz-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
.credential-info {
|
||||
.credential-id {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
nz-table {
|
||||
th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/*
|
||||
* Copyright [2022] [MaxKey of copyright http://www.maxkey.top]
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, ChangeDetectorRef, OnDestroy } from '@angular/core';
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
import { NzModalService } from 'ng-zorro-antd/modal';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { SettingsService } from '@delon/theme';
|
||||
import { Subject, takeUntil, finalize } from 'rxjs';
|
||||
|
||||
// 定义接口类型
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
credentialId: string;
|
||||
displayName: string;
|
||||
deviceType: string;
|
||||
signatureCount: number;
|
||||
createdDate: string;
|
||||
lastUsedDate?: string;
|
||||
status: number; // 修复:状态应为数字类型,1表示活跃,0表示禁用
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
userId?: string;
|
||||
id?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-passkey',
|
||||
templateUrl: './passkey.component.html',
|
||||
styleUrls: ['./passkey.component.less']
|
||||
})
|
||||
export class PasskeyComponent implements OnInit, OnDestroy {
|
||||
loading = false;
|
||||
passkeyList: PasskeyInfo[] = [];
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private msg: NzMessageService,
|
||||
private modal: NzModalService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private http: HttpClient,
|
||||
private settingsService: SettingsService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadPasskeys();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
loadPasskeys(): void {
|
||||
const userId = this.getCurrentUserId();
|
||||
if (!userId) {
|
||||
this.msg.error('无法获取当前用户ID,请重新登录');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.http.get<ApiResponse<PasskeyInfo[]>>(`/passkey/registration/list/${userId}`)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
finalize(() => {
|
||||
this.loading = false;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (response.code === 0) {
|
||||
this.passkeyList = response.data || [];
|
||||
} else {
|
||||
this.passkeyList = [];
|
||||
this.msg.warning(response.message || '获取Passkey列表失败');
|
||||
}
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
console.error('加载Passkey列表失败:', error);
|
||||
this.passkeyList = [];
|
||||
this.handleHttpError(error, '加载Passkey列表失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async registerPasskey(): Promise<void> {
|
||||
console.log('=== PASSKEY REGISTRATION DEBUG START ===');
|
||||
console.log('Passkey registration clicked at:', new Date().toISOString());
|
||||
|
||||
const userId = this.getCurrentUserId();
|
||||
console.log('Current user ID:', userId);
|
||||
|
||||
if (!userId) {
|
||||
console.error('No user ID available');
|
||||
this.msg.error('无法获取当前用户ID,请重新登录');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查浏览器是否支持 WebAuthn
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
console.error('WebAuthn not supported');
|
||||
this.msg.error('您的浏览器不支持 WebAuthn/Passkey 功能');
|
||||
return;
|
||||
}
|
||||
console.log('WebAuthn support confirmed');
|
||||
|
||||
if (this.loading) {
|
||||
console.log('Registration already in progress, ignoring click');
|
||||
return; // 防止重复点击
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const currentUser = this.settingsService.user as UserInfo;
|
||||
console.log('Current user info:', {
|
||||
userId: currentUser?.userId,
|
||||
username: currentUser?.username,
|
||||
displayName: currentUser?.displayName
|
||||
});
|
||||
|
||||
// 调用后端 API 获取注册选项
|
||||
console.log('Step 1: Requesting registration options from backend...');
|
||||
const registrationRequest = {
|
||||
userId: userId,
|
||||
username: currentUser?.username || 'unknown_user',
|
||||
displayName: currentUser?.displayName || '未知用户'
|
||||
};
|
||||
console.log('Registration request payload:', registrationRequest);
|
||||
|
||||
const beginResponse = await this.http.post<ApiResponse>('/passkey/registration/begin', registrationRequest).toPromise();
|
||||
console.log('Backend registration options response:', beginResponse);
|
||||
|
||||
if (!beginResponse || beginResponse.code !== 0) {
|
||||
console.error('Failed to get registration options:', beginResponse);
|
||||
throw new Error(beginResponse?.message || '获取注册选项失败');
|
||||
}
|
||||
|
||||
const regOptions = beginResponse.data;
|
||||
console.log('Registration options received:', regOptions);
|
||||
|
||||
if (!regOptions) {
|
||||
console.error('Empty registration options');
|
||||
throw new Error('注册选项为空');
|
||||
}
|
||||
|
||||
// 转换Base64字符串为ArrayBuffer
|
||||
console.log('Step 2: Converting registration options...');
|
||||
console.log('Original registration options:', {
|
||||
challengeLength: regOptions.challenge?.length,
|
||||
userIdLength: regOptions.user?.id?.length,
|
||||
excludeCredentialsCount: regOptions.excludeCredentials?.length || 0,
|
||||
timeout: regOptions.timeout,
|
||||
rpId: regOptions.rp?.id,
|
||||
rpName: regOptions.rp?.name
|
||||
});
|
||||
|
||||
const convertedOptions = this.convertRegistrationOptions(regOptions);
|
||||
console.log('Converted registration options:', {
|
||||
challengeLength: convertedOptions.challenge.byteLength,
|
||||
userIdLength: convertedOptions.user.id.byteLength,
|
||||
userName: convertedOptions.user.name,
|
||||
userDisplayName: convertedOptions.user.displayName,
|
||||
excludeCredentialsCount: convertedOptions.excludeCredentials?.length || 0,
|
||||
timeout: convertedOptions.timeout,
|
||||
rpId: convertedOptions.rp.id,
|
||||
rpName: convertedOptions.rp.name,
|
||||
pubKeyCredParamsCount: convertedOptions.pubKeyCredParams?.length || 0
|
||||
});
|
||||
|
||||
// 调用 WebAuthn API 进行注册
|
||||
console.log('Step 3: Calling WebAuthn API navigator.credentials.create()...');
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: convertedOptions
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
if (!credential) {
|
||||
console.error('No credential returned from WebAuthn API');
|
||||
throw new Error('凭证创建失败');
|
||||
}
|
||||
|
||||
console.log('=== REGISTRATION CREDENTIAL DEBUG INFO ===');
|
||||
console.log('Credential ID:', credential.id);
|
||||
console.log('Credential ID length:', credential.id.length);
|
||||
console.log('Credential type:', credential.type);
|
||||
console.log('Credential rawId length:', credential.rawId.byteLength);
|
||||
console.log('Credential rawId as base64:', this.arrayBufferToBase64(credential.rawId));
|
||||
|
||||
// 验证 credential.id 和 rawId 的一致性
|
||||
const rawIdBase64 = this.arrayBufferToBase64(credential.rawId);
|
||||
console.log('ID consistency check:');
|
||||
console.log(' credential.id:', credential.id);
|
||||
console.log(' rawId as base64:', rawIdBase64);
|
||||
console.log(' IDs match:', credential.id === rawIdBase64);
|
||||
|
||||
const credentialResponse = credential.response as AuthenticatorAttestationResponse;
|
||||
console.log('Authenticator response type:', credentialResponse.constructor.name);
|
||||
console.log('Attestation object length:', credentialResponse.attestationObject.byteLength);
|
||||
console.log('Client data JSON length:', credentialResponse.clientDataJSON.byteLength);
|
||||
console.log('=== END REGISTRATION CREDENTIAL DEBUG INFO ===');
|
||||
|
||||
// 将注册结果发送到后端保存
|
||||
console.log('Step 4: Sending registration result to backend...');
|
||||
const finishRequest = {
|
||||
userId: userId,
|
||||
challengeId: regOptions.challengeId,
|
||||
credentialId: credential.id,
|
||||
attestationObject: this.arrayBufferToBase64(credentialResponse.attestationObject),
|
||||
clientDataJSON: this.arrayBufferToBase64(credentialResponse.clientDataJSON)
|
||||
};
|
||||
console.log('Registration finish request payload:', {
|
||||
userId: finishRequest.userId,
|
||||
challengeId: finishRequest.challengeId,
|
||||
credentialId: finishRequest.credentialId,
|
||||
credentialIdLength: finishRequest.credentialId.length,
|
||||
attestationObjectLength: finishRequest.attestationObject.length,
|
||||
clientDataJSONLength: finishRequest.clientDataJSON.length
|
||||
});
|
||||
|
||||
const finishResponse = await this.http.post<ApiResponse<PasskeyInfo>>('/passkey/registration/finish', finishRequest).toPromise();
|
||||
console.log('Backend registration finish response:', finishResponse);
|
||||
|
||||
if (!finishResponse || finishResponse.code !== 0) {
|
||||
console.error('Backend registration verification failed:', finishResponse);
|
||||
throw new Error(finishResponse?.message || 'Passkey注册失败');
|
||||
}
|
||||
|
||||
const passkeyInfo = finishResponse.data;
|
||||
console.log('Registration successful, passkey info:', passkeyInfo);
|
||||
|
||||
if (passkeyInfo) {
|
||||
this.msg.success(`Passkey 注册成功!`);
|
||||
|
||||
// 添加新注册的Passkey到列表中
|
||||
console.log('Adding new passkey to list, current list length:', this.passkeyList.length);
|
||||
this.passkeyList.unshift(passkeyInfo);
|
||||
console.log('New list length:', this.passkeyList.length);
|
||||
this.cdr.detectChanges();
|
||||
console.log('=== PASSKEY REGISTRATION SUCCESS ===');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('=== PASSKEY REGISTRATION ERROR ===');
|
||||
console.error('Error type:', error.constructor.name);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
console.error('Full error object:', error);
|
||||
|
||||
// 如果是 WebAuthn 相关错误,提供更详细的信息
|
||||
if (error.name) {
|
||||
console.error('WebAuthn error name:', error.name);
|
||||
switch (error.name) {
|
||||
case 'NotAllowedError':
|
||||
console.error('User cancelled the operation or timeout occurred');
|
||||
break;
|
||||
case 'SecurityError':
|
||||
console.error('Security error - invalid domain or HTTPS required');
|
||||
break;
|
||||
case 'NotSupportedError':
|
||||
console.error('Operation not supported by authenticator');
|
||||
break;
|
||||
case 'InvalidStateError':
|
||||
console.error('Authenticator is in invalid state');
|
||||
break;
|
||||
case 'ConstraintError':
|
||||
console.error('Constraint error in authenticator');
|
||||
break;
|
||||
case 'NotReadableError':
|
||||
console.error('Authenticator data not readable');
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown WebAuthn error');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Passkey 注册失败:', error);
|
||||
this.handlePasskeyError(error);
|
||||
console.log('=== PASSKEY REGISTRATION DEBUG END ===');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.cdr.detectChanges();
|
||||
console.log('Registration loading state reset');
|
||||
}
|
||||
}
|
||||
|
||||
deletePasskey(credentialId: string): void {
|
||||
if (!credentialId) {
|
||||
this.msg.error('凭证ID无效');
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = this.getCurrentUserId();
|
||||
if (!userId) {
|
||||
this.msg.error('无法获取当前用户ID,请重新登录');
|
||||
return;
|
||||
}
|
||||
|
||||
this.http.delete<ApiResponse>(`/passkey/registration/delete/${userId}/${credentialId}`)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (response.code === 0) {
|
||||
this.msg.success('Passkey 删除成功');
|
||||
// 从本地列表中移除,避免重新加载
|
||||
this.passkeyList = this.passkeyList.filter(item => item.credentialId !== credentialId);
|
||||
this.cdr.detectChanges();
|
||||
} else {
|
||||
this.msg.error(response.message || 'Passkey 删除失败');
|
||||
}
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
console.error('删除Passkey失败:', error);
|
||||
this.handleHttpError(error, 'Passkey 删除失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmDeletePasskey(credentialId: string): void {
|
||||
this.modal.confirm({
|
||||
nzTitle: '确认删除',
|
||||
nzContent: '确定要删除这个 Passkey 吗?此操作不可撤销。',
|
||||
nzOkText: '删除',
|
||||
nzOkType: 'primary',
|
||||
nzOkDanger: true,
|
||||
nzCancelText: '取消',
|
||||
nzOnOk: () => {
|
||||
this.deletePasskey(credentialId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*/
|
||||
private getCurrentUserId(): string | null {
|
||||
const currentUser = this.settingsService.user as UserInfo;
|
||||
return currentUser?.userId || currentUser?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查浏览器是否支持WebAuthn
|
||||
*/
|
||||
private isWebAuthnSupported(): boolean {
|
||||
return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换注册选项中的Base64字符串为ArrayBuffer
|
||||
*/
|
||||
private convertRegistrationOptions(regOptions: any): PublicKeyCredentialCreationOptions {
|
||||
return {
|
||||
...regOptions,
|
||||
challenge: this.base64ToArrayBuffer(regOptions.challenge),
|
||||
user: {
|
||||
...regOptions.user,
|
||||
id: this.base64ToArrayBuffer(regOptions.user.id)
|
||||
},
|
||||
excludeCredentials: regOptions.excludeCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: this.base64ToArrayBuffer(cred.id)
|
||||
})) || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Passkey相关错误
|
||||
*/
|
||||
private handlePasskeyError(error: any): void {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
this.msg.error('Passkey 注册被取消或失败');
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
this.msg.error('您的设备不支持 Passkey 功能');
|
||||
} else if (error.name === 'SecurityError') {
|
||||
this.msg.error('安全错误,请检查HTTPS连接');
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
this.msg.error('设备状态无效,请重试');
|
||||
} else {
|
||||
this.msg.error(error.message || 'Passkey 注册失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理HTTP错误
|
||||
*/
|
||||
private handleHttpError(error: HttpErrorResponse, defaultMessage: string): void {
|
||||
if (error.status === 401) {
|
||||
this.msg.error('认证失败,请重新登录');
|
||||
} else if (error.status === 403) {
|
||||
this.msg.error('权限不足');
|
||||
} else if (error.status === 404) {
|
||||
this.msg.error('接口不存在');
|
||||
} else if (error.status >= 500) {
|
||||
this.msg.error('服务器错误,请稍后重试');
|
||||
} else {
|
||||
this.msg.error(defaultMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 将Base64URL字符串转换为ArrayBuffer
|
||||
*/
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
try {
|
||||
// 将Base64URL转换为标准Base64
|
||||
let normalizedBase64 = base64
|
||||
.replace(/-/g, '+') // 替换 - 为 +
|
||||
.replace(/_/g, '/'); // 替换 _ 为 /
|
||||
|
||||
// 添加必要的填充
|
||||
const padded = normalizedBase64 + '='.repeat((4 - normalizedBase64.length % 4) % 4);
|
||||
const binaryString = atob(padded);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
} catch (error) {
|
||||
console.error('Base64解码失败:', error);
|
||||
throw new Error('Base64解码失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将ArrayBuffer转换为Base64URL字符串(WebAuthn标准格式)
|
||||
*/
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
try {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// 转换为标准Base64,然后转换为Base64URL格式
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-') // 替换 + 为 -
|
||||
.replace(/\//g, '_') // 替换 / 为 _
|
||||
.replace(/=/g, ''); // 移除填充字符 =
|
||||
} catch (error) {
|
||||
console.error('ArrayBuffer编码失败:', error);
|
||||
throw new Error('ArrayBuffer编码失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,12 @@
|
||||
{{ 'app.login.login' | i18n }}
|
||||
</button>
|
||||
</nz-form-item>
|
||||
<nz-form-item *ngIf="loginType == 'normal'">
|
||||
<button nz-button type="button" nzType="default" nzSize="large" (click)="passkeyLogin()" nzBlock>
|
||||
<i nz-icon nzType="safety-certificate" nzTheme="outline"></i>
|
||||
{{ 'mxk.login.passkey-login' | i18n }}
|
||||
</button>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
<div class="other" *ngIf="loginType == 'normal'">
|
||||
{{ 'app.login.sign-in-with' | i18n }}
|
||||
|
||||
@@ -77,7 +77,8 @@ export class UserLoginComponent implements OnInit, OnDestroy {
|
||||
private reuseTabService: ReuseTabService,
|
||||
private route: ActivatedRoute,
|
||||
private msg: NzMessageService,
|
||||
private cdr: ChangeDetectorRef
|
||||
private cdr: ChangeDetectorRef,
|
||||
private http: _HttpClient
|
||||
) {
|
||||
this.form = fb.group({
|
||||
userName: [null, [Validators.required]],
|
||||
@@ -500,4 +501,281 @@ export class UserLoginComponent implements OnInit, OnDestroy {
|
||||
this.cdr.detectChanges();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Passkey 无用户名登录
|
||||
*/
|
||||
async passkeyLogin(): Promise<void> {
|
||||
console.log('=== PASSKEY LOGIN DEBUG START ===');
|
||||
console.log('Passkey usernameless login clicked at:', new Date().toISOString());
|
||||
|
||||
try {
|
||||
// 检查浏览器是否支持 WebAuthn
|
||||
if (!window.PublicKeyCredential) {
|
||||
console.error('WebAuthn not supported');
|
||||
this.msg.error('您的浏览器不支持 WebAuthn/Passkey 功能');
|
||||
return;
|
||||
}
|
||||
console.log('WebAuthn support confirmed');
|
||||
|
||||
this.loading = true;
|
||||
this.cdr.detectChanges();
|
||||
|
||||
// 1. 调用后端 API 获取认证选项(不传递任何用户信息)
|
||||
console.log('Step 1: Requesting authentication options from backend...');
|
||||
let authOptionsResponse;
|
||||
try {
|
||||
authOptionsResponse = await this.http.post<any>('/passkey/authentication/begin?_allow_anonymous=true', {}).toPromise();
|
||||
} catch (httpError: any) {
|
||||
console.error('HTTP error occurred:', httpError);
|
||||
// 处理HTTP错误,提取错误信息
|
||||
let errorMessage = '获取认证选项失败';
|
||||
if (httpError.error && httpError.error.message) {
|
||||
errorMessage = httpError.error.message;
|
||||
} else if (httpError.message) {
|
||||
errorMessage = httpError.message;
|
||||
}
|
||||
|
||||
// 检查是否是没有注册 Passkey 的错误
|
||||
if (errorMessage.includes('没有注册任何 Passkey') ||
|
||||
errorMessage.includes('No Passkeys registered') ||
|
||||
errorMessage.includes('还没有注册任何 Passkey') ||
|
||||
errorMessage.includes('系统中还没有注册任何 Passkey')) {
|
||||
// 直接显示友好提示并返回,不抛出错误避免被全局拦截器捕获
|
||||
this.msg.warning('还未注册 Passkey,请注册 Passkey');
|
||||
console.log('=== PASSKEY LOGIN DEBUG END ===');
|
||||
return;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
console.log('Backend auth options response:', authOptionsResponse);
|
||||
|
||||
if (!authOptionsResponse || authOptionsResponse.code !== 0) {
|
||||
console.error('Failed to get auth options:', authOptionsResponse);
|
||||
// 检查是否是没有注册 Passkey 的错误
|
||||
const errorMessage = authOptionsResponse?.message || '获取认证选项失败';
|
||||
if (errorMessage.includes('没有注册任何 Passkey') ||
|
||||
errorMessage.includes('No Passkeys registered') ||
|
||||
errorMessage.includes('还没有注册任何 Passkey') ||
|
||||
errorMessage.includes('系统中还没有注册任何 Passkey')) {
|
||||
// 直接显示友好提示并返回,不抛出错误避免被全局拦截器捕获
|
||||
this.msg.warning('还未注册 Passkey,请注册 Passkey');
|
||||
console.log('=== PASSKEY LOGIN DEBUG END ===');
|
||||
return;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const authOptions = authOptionsResponse.data;
|
||||
console.log('Auth options received:', authOptions);
|
||||
|
||||
// 检查返回的数据是否有效
|
||||
if (!authOptions || !authOptions.challenge) {
|
||||
console.error('Invalid auth options:', authOptions);
|
||||
throw new Error('服务器返回的认证选项无效');
|
||||
}
|
||||
|
||||
// 2. 转换认证选项格式
|
||||
console.log('Step 2: Converting authentication options...');
|
||||
const convertedOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: this.base64ToArrayBuffer(authOptions.challenge),
|
||||
timeout: authOptions.timeout || 60000,
|
||||
rpId: authOptions.rpId,
|
||||
userVerification: authOptions.userVerification || 'preferred'
|
||||
// 注意:不设置 allowCredentials,让认证器自动选择可用的凭据
|
||||
};
|
||||
console.log('Converted options:', {
|
||||
challengeLength: convertedOptions.challenge.byteLength,
|
||||
timeout: convertedOptions.timeout,
|
||||
rpId: convertedOptions.rpId,
|
||||
userVerification: convertedOptions.userVerification,
|
||||
allowCredentials: convertedOptions.allowCredentials || 'undefined (auto-select)'
|
||||
});
|
||||
|
||||
// 3. 调用 WebAuthn API 进行认证
|
||||
console.log('Step 3: Calling WebAuthn API navigator.credentials.get()...');
|
||||
console.log('Available authenticators will be queried automatically');
|
||||
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: convertedOptions
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
if (!credential) {
|
||||
console.error('No credential returned from WebAuthn API');
|
||||
throw new Error('认证失败');
|
||||
}
|
||||
|
||||
console.log('=== CREDENTIAL DEBUG INFO ===');
|
||||
console.log('Credential ID:', credential.id);
|
||||
console.log('Credential ID length:', credential.id.length);
|
||||
console.log('Credential type:', credential.type);
|
||||
console.log('Credential rawId length:', credential.rawId.byteLength);
|
||||
console.log('Credential rawId as base64:', this.arrayBufferToBase64(credential.rawId));
|
||||
|
||||
// 验证 credential.id 和 rawId 的一致性
|
||||
const rawIdBase64 = this.arrayBufferToBase64(credential.rawId);
|
||||
console.log('ID consistency check:');
|
||||
console.log(' credential.id:', credential.id);
|
||||
console.log(' rawId as base64:', rawIdBase64);
|
||||
console.log(' IDs match:', credential.id === rawIdBase64);
|
||||
|
||||
const credentialResponse = credential.response as AuthenticatorAssertionResponse;
|
||||
console.log('Authenticator response type:', credentialResponse.constructor.name);
|
||||
console.log('User handle:', credentialResponse.userHandle ? this.arrayBufferToBase64(credentialResponse.userHandle) : 'null');
|
||||
console.log('=== END CREDENTIAL DEBUG INFO ===');
|
||||
|
||||
// 4. 将认证结果发送到后端验证
|
||||
console.log('Step 4: Sending credential to backend for verification...');
|
||||
const requestPayload = {
|
||||
challengeId: authOptions.challengeId,
|
||||
credentialId: credential.id,
|
||||
authenticatorData: this.arrayBufferToBase64(credentialResponse.authenticatorData),
|
||||
clientDataJSON: this.arrayBufferToBase64(credentialResponse.clientDataJSON),
|
||||
signature: this.arrayBufferToBase64(credentialResponse.signature),
|
||||
userHandle: credentialResponse.userHandle ? this.arrayBufferToBase64(credentialResponse.userHandle) : null
|
||||
};
|
||||
console.log('Request payload to backend:', {
|
||||
challengeId: requestPayload.challengeId,
|
||||
credentialId: requestPayload.credentialId,
|
||||
credentialIdLength: requestPayload.credentialId.length,
|
||||
authenticatorDataLength: requestPayload.authenticatorData.length,
|
||||
clientDataJSONLength: requestPayload.clientDataJSON.length,
|
||||
signatureLength: requestPayload.signature.length,
|
||||
userHandle: requestPayload.userHandle
|
||||
});
|
||||
|
||||
const finishResponse = await this.http.post<any>('/passkey/authentication/finish?_allow_anonymous=true', requestPayload).toPromise();
|
||||
console.log('Backend finish response:', finishResponse);
|
||||
|
||||
if (!finishResponse || finishResponse.code !== 0) {
|
||||
console.error('Backend verification failed:', finishResponse);
|
||||
throw new Error(finishResponse?.message || 'Passkey认证失败');
|
||||
}
|
||||
|
||||
// 5. 认证成功,设置用户信息并跳转
|
||||
console.log('Step 5: Authentication successful, setting user info...');
|
||||
const authResult = finishResponse.data;
|
||||
console.log('Auth result received:', authResult);
|
||||
|
||||
this.msg.success(`Passkey 登录成功!欢迎 ${authResult.username || '用户'}`);
|
||||
|
||||
// 清空路由复用信息
|
||||
console.log('Clearing reuse tab service...');
|
||||
this.reuseTabService.clear();
|
||||
|
||||
// 设置用户Token信息
|
||||
if (authResult && authResult.userId) {
|
||||
console.log('Valid auth result with userId:', authResult.userId);
|
||||
// 构建完整的认证信息对象,包含 SimpleGuard 所需的 token 和 ticket
|
||||
const userInfo = {
|
||||
id: authResult.userId,
|
||||
userId: authResult.userId,
|
||||
username: authResult.username,
|
||||
displayName: authResult.displayName || authResult.username,
|
||||
email: authResult.email || '',
|
||||
authTime: authResult.authTime,
|
||||
authType: 'passkey',
|
||||
// 关键:包含认证所需的 token 和 ticket
|
||||
token: authResult.token || authResult.congress || '',
|
||||
ticket: authResult.ticket || authResult.onlineTicket || '',
|
||||
// 其他可能需要的字段
|
||||
remeberMe: false,
|
||||
passwordSetType: authResult.passwordSetType || 'normal',
|
||||
authorities: authResult.authorities || []
|
||||
};
|
||||
|
||||
console.log('Setting auth info:', userInfo);
|
||||
|
||||
// 设置认证信息
|
||||
this.authnService.auth(userInfo);
|
||||
|
||||
// 使用 navigate 方法进行跳转,它会处理 StartupService 的重新加载
|
||||
console.log('Navigating with auth result...');
|
||||
this.authnService.navigate(authResult);
|
||||
console.log('=== PASSKEY LOGIN SUCCESS ===');
|
||||
} else {
|
||||
console.error('Invalid auth result - missing userId:', authResult);
|
||||
throw new Error('认证成功但用户数据无效');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('=== PASSKEY LOGIN ERROR ===');
|
||||
console.error('Error type:', error.constructor.name);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
console.error('Full error object:', error);
|
||||
|
||||
// 检查是否是没有注册 Passkey 的错误
|
||||
if (error.message && (error.message.includes('PASSKEY_NOT_REGISTERED') ||
|
||||
error.message.includes('没有找到可用的凭据') ||
|
||||
error.message.includes('No credentials available') ||
|
||||
error.message.includes('用户未注册') ||
|
||||
error.message.includes('credential not found') ||
|
||||
error.message.includes('没有注册任何 Passkey') ||
|
||||
error.message.includes('No Passkeys registered') ||
|
||||
error.message.includes('还没有注册任何 Passkey'))) {
|
||||
this.msg.warning('还未注册 Passkey,请注册 Passkey');
|
||||
console.log('=== PASSKEY LOGIN DEBUG END ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是 WebAuthn 相关错误,提供更详细的信息
|
||||
if (error.name) {
|
||||
console.error('WebAuthn error name:', error.name);
|
||||
switch (error.name) {
|
||||
case 'NotAllowedError':
|
||||
console.error('User cancelled the operation or timeout occurred');
|
||||
// 检查是否是因为没有可用凭据导致的取消
|
||||
this.msg.warning('Passkey 登录已取消。如果您还没有注册 Passkey,请先注册后再使用');
|
||||
break;
|
||||
case 'SecurityError':
|
||||
console.error('Security error - invalid domain or HTTPS required');
|
||||
this.msg.error('安全错误:请确保在 HTTPS 环境下使用 Passkey 功能');
|
||||
break;
|
||||
case 'NotSupportedError':
|
||||
console.error('Operation not supported by authenticator');
|
||||
this.msg.error('您的设备不支持 Passkey 功能');
|
||||
break;
|
||||
case 'InvalidStateError':
|
||||
console.error('Authenticator is in invalid state');
|
||||
this.msg.error('认证器状态异常,请重试');
|
||||
break;
|
||||
case 'ConstraintError':
|
||||
console.error('Constraint error in authenticator');
|
||||
this.msg.error('认证器约束错误,请重试');
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown WebAuthn error');
|
||||
this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式'));
|
||||
}
|
||||
} else {
|
||||
this.msg.error('Passkey 登录失败:' + (error.message || '请重试或使用其他登录方式'));
|
||||
}
|
||||
console.log('=== PASSKEY LOGIN DEBUG END ===');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.cdr.detectChanges();
|
||||
console.log('Login loading state reset');
|
||||
}
|
||||
}
|
||||
|
||||
// 添加辅助方法
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,14 @@
|
||||
"icon": "anticon-appstore",
|
||||
"acl": "ROLE_USER",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"text": "Passkey 注册",
|
||||
"i18n": "mxk.menu.config.passkey",
|
||||
"link": "/config/passkey",
|
||||
"icon": "anticon-safety-certificate",
|
||||
"acl": "ROLE_USER",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"text": "二次认证",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"signup": "Sign up",
|
||||
"login": "Login",
|
||||
"twoFactor": "2-Factor Authentication",
|
||||
"passkey-login": "Passkey Login",
|
||||
"passkey-register": "Register Passkey",
|
||||
"text.username": "Username",
|
||||
"text.mobile": "Mobile Number",
|
||||
"text.password": "Password",
|
||||
@@ -45,6 +47,7 @@
|
||||
"": "Settings",
|
||||
"setting": "Setting",
|
||||
"profile": "Profile",
|
||||
"passkey": "Passkey Registration",
|
||||
"mfa": "MFA",
|
||||
"password": "Password",
|
||||
"socialsassociate": "Socials",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"signup": "用户注册",
|
||||
"login": "登录",
|
||||
"twoFactor": "二次身份认证",
|
||||
"passkey-login": "Passkey登录",
|
||||
"passkey-register": "注册Passkey",
|
||||
"text.username": "用户名",
|
||||
"text.mobile": "手机号码",
|
||||
"text.password": "密码",
|
||||
@@ -45,6 +47,7 @@
|
||||
"": "配置",
|
||||
"setting": "基本设置",
|
||||
"profile": "我的资料",
|
||||
"passkey": "Passkey 注册",
|
||||
"mfa": "二次认证",
|
||||
"socialsassociate": "社交关联",
|
||||
"password": "密码修改",
|
||||
|
||||
@@ -32,6 +32,26 @@
|
||||
</div>
|
||||
<div nz-col nzXs="24" nzSm="12" nzMd="6" class="mb-md">
|
||||
<div nz-row nzType="flex" nzAlign="middle" class="bg-success rounded-md">
|
||||
<div nz-col nzSpan="12" class="p-md text-white">
|
||||
<div class="h2 mt0">{{ dayCount }}</div>
|
||||
<p class="text-nowrap mb0">{{ 'mxk.home.dayCount' | i18n }}</p>
|
||||
</div>
|
||||
<div nz-col nzSpan="12">
|
||||
<g2-mini-bar
|
||||
*ngIf="simulateData"
|
||||
height="35"
|
||||
color="#fff"
|
||||
borderWidth="3"
|
||||
[padding]="[5, 30]"
|
||||
[data]="simulateData"
|
||||
tooltipType="mini"
|
||||
(ready)="fixDark($event)"
|
||||
></g2-mini-bar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div nz-col nzXs="24" nzSm="12" nzMd="6" class="mb-md">
|
||||
<div nz-row nzType="flex" nzAlign="middle" class="bg-orange rounded-md">
|
||||
<div nz-col nzSpan="12" class="p-md text-white">
|
||||
<div class="h2 mt0">{{ newUsers }}</div>
|
||||
<p class="text-nowrap mb0">{{ 'mxk.home.newUsers' | i18n }}</p>
|
||||
|
||||
@@ -48,6 +48,7 @@ dependencies {
|
||||
implementation project(":maxkey-starter:maxkey-starter-captcha")
|
||||
implementation project(":maxkey-starter:maxkey-starter-ip2location")
|
||||
implementation project(":maxkey-starter:maxkey-starter-otp")
|
||||
implementation project(":maxkey-starter:maxkey-starter-passkey")
|
||||
implementation project(":maxkey-starter:maxkey-starter-sms")
|
||||
implementation project(":maxkey-starter:maxkey-starter-social")
|
||||
implementation project(":maxkey-starter:maxkey-starter-web")
|
||||
@@ -64,4 +65,11 @@ dependencies {
|
||||
implementation project(":maxkey-protocols:maxkey-protocol-oauth-2.0")
|
||||
implementation project(":maxkey-protocols:maxkey-protocol-saml-2.0")
|
||||
implementation project(":maxkey-protocols:maxkey-protocol-jwt")
|
||||
|
||||
// WebAuthn4J 完整依赖
|
||||
implementation "com.webauthn4j:webauthn4j-core:${webauthn4jVersion}"
|
||||
implementation "com.webauthn4j:webauthn4j-util:${webauthn4jVersion}"
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor'
|
||||
implementation 'commons-codec:commons-codec'
|
||||
}
|
||||
@@ -29,3 +29,10 @@ spring.main.banner-mode =log
|
||||
############################################################################
|
||||
spring.profiles.active =${SERVER_PROFILES:maxkey}
|
||||
|
||||
############################################################################
|
||||
# Passkey Configuration #
|
||||
############################################################################
|
||||
maxkey.passkey.enabled=true
|
||||
maxkey.passkey.relying-party.name=MaxKey
|
||||
maxkey.passkey.relying-party.id=localhost
|
||||
maxkey.passkey.relying-party.allowed-origins=http://localhost:8527,http://localhost:8080
|
||||
Reference in New Issue
Block a user