mirror of
https://github.com/zitadel/zitadel.git
synced 2025-06-11 01:28:34 +00:00
Merge branch 'main' into next
# Conflicts: # cmd/setup/11.go # console/src/app/utils/language.ts
This commit is contained in:
commit
5060c7463a
@ -53,23 +53,21 @@ func (mig *AddEventCreatedAt) Execute(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for i := 0; ; i++ {
|
||||
var count int64
|
||||
err := crdb.ExecuteTx(ctx, mig.dbClient.DB, nil, func(tx *sql.Tx) error {
|
||||
var affected int64
|
||||
err = crdb.ExecuteTx(ctx, mig.dbClient.DB, nil, func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(setCreatedAt, mig.BulkAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count, _ = res.RowsAffected()
|
||||
logging.WithFields("affected", count).Info("created_at updated")
|
||||
|
||||
affected, _ = res.RowsAffected()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logging.WithFields("step", "11", "iteration", i, "count", count).Info("set created_at iteration done")
|
||||
if count < int64(mig.BulkAmount) {
|
||||
logging.WithFields("step", "11", "iteration", i, "affected", affected).Info("set created_at iteration done")
|
||||
if affected < int64(mig.BulkAmount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -47,6 +47,7 @@ import (
|
||||
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
|
||||
"github.com/zitadel/zitadel/internal/authz"
|
||||
authz_repo "github.com/zitadel/zitadel/internal/authz/repository"
|
||||
authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
|
||||
@ -145,6 +146,11 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error
|
||||
keys.SAML,
|
||||
config.InternalAuthZ.RolePermissionMappings,
|
||||
sessionTokenVerifier,
|
||||
func(q *query.Queries) domain.PermissionCheck {
|
||||
return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
|
||||
return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot start queries: %w", err)
|
||||
|
4826
console/package-lock.json
generated
4826
console/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,18 +12,18 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.0.1",
|
||||
"@angular/cdk": "^16.0.1",
|
||||
"@angular/common": "^16.0.1",
|
||||
"@angular/compiler": "^16.0.1",
|
||||
"@angular/core": "^16.0.1",
|
||||
"@angular/forms": "^16.0.1",
|
||||
"@angular/material": "^16.0.1",
|
||||
"@angular/material-moment-adapter": "^16.0.1",
|
||||
"@angular/platform-browser": "^16.0.1",
|
||||
"@angular/platform-browser-dynamic": "^16.0.1",
|
||||
"@angular/router": "^16.0.1",
|
||||
"@angular/service-worker": "^16.0.1",
|
||||
"@angular/animations": "^16.1.2",
|
||||
"@angular/cdk": "^16.1.2",
|
||||
"@angular/common": "^16.1.2",
|
||||
"@angular/compiler": "^16.1.2",
|
||||
"@angular/core": "^16.1.2",
|
||||
"@angular/forms": "^16.1.2",
|
||||
"@angular/material": "^16.1.2",
|
||||
"@angular/material-moment-adapter": "^16.1.2",
|
||||
"@angular/platform-browser": "^16.1.2",
|
||||
"@angular/platform-browser-dynamic": "^16.1.2",
|
||||
"@angular/router": "^16.1.2",
|
||||
"@angular/service-worker": "^16.1.2",
|
||||
"@ctrl/ngx-codemirror": "^6.1.0",
|
||||
"@grpc/grpc-js": "^1.8.14",
|
||||
"@ngx-translate/core": "^14.0.0",
|
||||
@ -41,7 +41,8 @@
|
||||
"libphonenumber-js": "^1.10.30",
|
||||
"material-design-icons-iconfont": "^6.1.1",
|
||||
"moment": "^2.29.4",
|
||||
"ngx-color": "^8.0.3",
|
||||
"opentype.js": "^1.3.4",
|
||||
"ngx-color": "^9.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tslib": "^2.4.1",
|
||||
@ -49,25 +50,26 @@
|
||||
"zone.js": "~0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^16.0.1",
|
||||
"@angular-devkit/build-angular": "^16.1.1",
|
||||
"@angular-eslint/builder": "16.0.1",
|
||||
"@angular-eslint/eslint-plugin": "16.0.1",
|
||||
"@angular-eslint/eslint-plugin-template": "16.0.1",
|
||||
"@angular-eslint/schematics": "16.0.1",
|
||||
"@angular-eslint/template-parser": "16.0.1",
|
||||
"@angular/cli": "^16.0.1",
|
||||
"@angular/compiler-cli": "^16.0.1",
|
||||
"@angular/language-service": "^16.0.1",
|
||||
"@angular/cli": "^16.1.1",
|
||||
"@angular/compiler-cli": "^16.1.2",
|
||||
"@angular/language-service": "^16.1.2",
|
||||
"@bufbuild/buf": "^1.18.0-1",
|
||||
"@types/file-saver": "^2.0.2",
|
||||
"@types/google-protobuf": "^3.15.3",
|
||||
"@types/jasmine": "~4.3.0",
|
||||
"@types/jasmine": "~4.3.3",
|
||||
"@types/jasminewd2": "~2.0.10",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/node": "^18.15.11",
|
||||
"@types/opentype.js": "^1.3.4",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.11",
|
||||
"@typescript-eslint/parser": "^5.59.5",
|
||||
"codelyzer": "^6.0.2",
|
||||
"eslint": "^8.39.0",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommonModule, registerLocaleData } from '@angular/common';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import localeBg from '@angular/common/locales/bg';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import localeEn from '@angular/common/locales/en';
|
||||
import localeEs from '@angular/common/locales/es';
|
||||
@ -80,6 +81,8 @@ registerLocaleData(localePl);
|
||||
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/pl.json'));
|
||||
registerLocaleData(localeZh);
|
||||
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/zh.json'));
|
||||
registerLocaleData(localeBg);
|
||||
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/bg.json'));
|
||||
|
||||
export class WebpackTranslateLoader implements TranslateLoader {
|
||||
getTranslation(lang: string): Observable<any> {
|
||||
|
@ -1,16 +1,33 @@
|
||||
import { Directive, ElementRef, HostListener, Renderer2 } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { take } from 'rxjs';
|
||||
import { NavigationService } from 'src/app/services/navigation.service';
|
||||
|
||||
@Directive({
|
||||
selector: '[cnslBack]',
|
||||
})
|
||||
export class BackDirective {
|
||||
new: Boolean = false;
|
||||
@HostListener('click')
|
||||
onClick(): void {
|
||||
this.navigation.back();
|
||||
// Go back again to avoid create dialog starts again
|
||||
if (this.new) {
|
||||
this.navigation.back();
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private navigation: NavigationService, private elRef: ElementRef, private renderer2: Renderer2) {
|
||||
constructor(
|
||||
private navigation: NavigationService,
|
||||
private elRef: ElementRef,
|
||||
private renderer2: Renderer2,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
// Check if a new element was created using a create dialog
|
||||
this.route.queryParams.pipe(take(1)).subscribe((params) => {
|
||||
this.new = params['new'];
|
||||
});
|
||||
|
||||
if (navigation.isBackPossible) {
|
||||
// this.renderer2.removeStyle(this.elRef.nativeElement, 'visibility');
|
||||
} else {
|
||||
|
@ -19,6 +19,7 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
import { InfoSectionType } from '../../info-section/info-section.component';
|
||||
import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component';
|
||||
import { PolicyComponentServiceType } from '../policy-component-types.enum';
|
||||
@ -109,7 +110,7 @@ export class LoginTextsComponent implements OnInit, OnDestroy {
|
||||
@Input() public serviceType: PolicyComponentServiceType = PolicyComponentServiceType.MGMT;
|
||||
|
||||
public KeyNamesArray: string[] = KeyNamesArray;
|
||||
public LOCALES: string[] = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'];
|
||||
public LOCALES: string[] = supportedLanguages;
|
||||
|
||||
private sub: Subscription = new Subscription();
|
||||
|
||||
|
@ -47,6 +47,7 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
import { InfoSectionType } from '../../info-section/info-section.component';
|
||||
import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component';
|
||||
import { PolicyComponentServiceType } from '../policy-component-types.enum';
|
||||
@ -441,7 +442,7 @@ export class MessageTextsComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
public locale: string = 'en';
|
||||
public LOCALES: string[] = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'];
|
||||
public LOCALES: string[] = supportedLanguages;
|
||||
private sub: Subscription = new Subscription();
|
||||
public canWrite$: Observable<boolean> = this.authService.isAllowed([
|
||||
this.serviceType === PolicyComponentServiceType.ADMIN
|
||||
|
@ -492,7 +492,9 @@
|
||||
</cnsl-info-section>
|
||||
<div class="font-preview" *ngIf="previewData.fontUrl; else addFontButton">
|
||||
<mat-icon class="icon">text_fields</mat-icon>
|
||||
|
||||
<span class="font-name" [ngStyle]="fontName ? { 'font-family': 'brandingFont' } : { '': '' }">{{
|
||||
fontName
|
||||
}}</span>
|
||||
<span class="fill-space"></span>
|
||||
|
||||
<button
|
||||
@ -634,6 +636,7 @@
|
||||
class="preview"
|
||||
[ngClass]="{ darkmode: theme === Theme.DARK, lightmode: theme === Theme.LIGHT }"
|
||||
[policy]="view === View.PREVIEW ? previewData : data"
|
||||
[ngStyle]="fontName ? { 'font-family': 'brandingFont' } : { '': '' }"
|
||||
>
|
||||
</cnsl-preview>
|
||||
</div>
|
||||
|
@ -243,8 +243,6 @@
|
||||
}
|
||||
|
||||
.font-preview {
|
||||
height: 70px;
|
||||
width: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -252,22 +250,17 @@
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid map-get($foreground, divider);
|
||||
position: relative;
|
||||
padding: 1rem 0.5rem 1rem 0.75rem;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
.font-name {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dl-btn {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
transform: translateX(50%) translateY(-50%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -31,6 +31,7 @@ import {
|
||||
} from 'src/app/services/theme.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
import * as opentype from 'opentype.js';
|
||||
import { InfoSectionType } from '../../info-section/info-section.component';
|
||||
import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component';
|
||||
import { PolicyComponentServiceType } from '../policy-component-types.enum';
|
||||
@ -88,6 +89,8 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
public ColorType: any = ColorType;
|
||||
public AssetType: any = AssetType;
|
||||
|
||||
public fontName = '';
|
||||
|
||||
public refreshPreview: EventEmitter<void> = new EventEmitter();
|
||||
public org!: Org.AsObject;
|
||||
public InfoSectionType: any = InfoSectionType;
|
||||
@ -180,6 +183,10 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
this.getFontName(file);
|
||||
this.previewNewFont(file);
|
||||
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
return this.handleFontUploadPromise(this.assetService.upload(AssetEndpoint.MGMTFONT, formData, this.org.id));
|
||||
@ -199,6 +206,7 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
this.getPreviewData().then((data) => {
|
||||
if (data.policy) {
|
||||
this.previewData = data.policy;
|
||||
this.fontName = '';
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
@ -374,6 +382,11 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
.then((data) => {
|
||||
if (data.policy) {
|
||||
this.previewData = data.policy;
|
||||
if (this.previewData?.fontUrl) {
|
||||
this.fetchFontMetadataAndPreview(this.previewData.fontUrl);
|
||||
} else {
|
||||
this.fontName = 'Could not parse font name';
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
})
|
||||
@ -385,6 +398,11 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
.then((data) => {
|
||||
if (data.policy) {
|
||||
this.data = data.policy;
|
||||
if (this.data?.fontUrl) {
|
||||
this.fetchFontMetadataAndPreview(this.data?.fontUrl);
|
||||
} else {
|
||||
this.fontName = 'Could not parse font name';
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
})
|
||||
@ -678,6 +696,45 @@ export class PrivateLabelingPolicyComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private fetchFontMetadataAndPreview(url: string): void {
|
||||
fetch(url)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
this.getFontName(blob);
|
||||
this.previewNewFont(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private getFontName(blob: Blob): void {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target && e.target.result) {
|
||||
try {
|
||||
const font = opentype.parse(e.target.result);
|
||||
this.fontName = font.names.fullName['en'];
|
||||
} catch (e) {
|
||||
this.fontName = 'Could not parse font name';
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(blob);
|
||||
}
|
||||
|
||||
private previewNewFont(blob: Blob): void {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
if (e.target) {
|
||||
let customFont = new FontFace('brandingFont', `url(${e.target.result})`);
|
||||
// typescript complains that add is not found but
|
||||
// indeed it is https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/add
|
||||
// @ts-ignore
|
||||
document.fonts.add(customFont);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * defaults to false because urls are distinct anyway
|
||||
// */
|
||||
|
@ -98,6 +98,7 @@ export class ActionTableComponent implements OnInit {
|
||||
const dialogRef = this.dialog.open(AddActionDialogComponent, {
|
||||
data: {},
|
||||
width: '500px',
|
||||
disableClose: true,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((req: CreateActionRequest) => {
|
||||
@ -120,6 +121,7 @@ export class ActionTableComponent implements OnInit {
|
||||
action: action,
|
||||
},
|
||||
width: '500px',
|
||||
disableClose: true,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((req: UpdateActionRequest) => {
|
||||
|
@ -2,27 +2,29 @@
|
||||
<span *ngIf="id" class="action-dialog-title" mat-dialog-title>{{ 'FLOWS.DIALOG.UPDATE.TITLE' | translate }}</span>
|
||||
|
||||
<div mat-dialog-content>
|
||||
<cnsl-form-field class="form-field">
|
||||
<cnsl-label>{{ 'FLOWS.NAME' | translate }}</cnsl-label>
|
||||
<input cnslInput [(ngModel)]="name" />
|
||||
</cnsl-form-field>
|
||||
<form [formGroup]="form">
|
||||
<cnsl-form-field class="form-field">
|
||||
<cnsl-label>{{ 'FLOWS.NAME' | translate }}</cnsl-label>
|
||||
<input cnslInput formControlName="name" />
|
||||
</cnsl-form-field>
|
||||
|
||||
<ngx-codemirror
|
||||
*ngIf="opened$ | async"
|
||||
[(ngModel)]="script"
|
||||
[options]="{
|
||||
lineNumbers: true,
|
||||
theme: 'material',
|
||||
mode: 'javascript'
|
||||
}"
|
||||
></ngx-codemirror>
|
||||
<ngx-codemirror
|
||||
*ngIf="opened$ | async"
|
||||
formControlName="script"
|
||||
[options]="{
|
||||
lineNumbers: true,
|
||||
theme: 'material',
|
||||
mode: 'javascript'
|
||||
}"
|
||||
></ngx-codemirror>
|
||||
|
||||
<cnsl-form-field class="form-field">
|
||||
<cnsl-label>{{ 'FLOWS.TIMEOUTINSEC' | translate }}</cnsl-label>
|
||||
<input type="number" cnslInput [(ngModel)]="durationInSec" />
|
||||
</cnsl-form-field>
|
||||
<cnsl-form-field class="form-field">
|
||||
<cnsl-label>{{ 'FLOWS.TIMEOUTINSEC' | translate }}</cnsl-label>
|
||||
<input type="number" cnslInput formControlName="durationInSec" />
|
||||
</cnsl-form-field>
|
||||
|
||||
<mat-checkbox [(ngModel)]="allowedToFail">{{ 'FLOWS.ALLOWEDTOFAIL' | translate }}</mat-checkbox>
|
||||
<mat-checkbox formControlName="allowedToFail">{{ 'FLOWS.ALLOWEDTOFAIL' | translate }}</mat-checkbox>
|
||||
</form>
|
||||
</div>
|
||||
<div mat-dialog-actions class="action">
|
||||
<button *ngIf="id" mat-stroked-button color="warn" (click)="deleteAndCloseDialog()">
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import {
|
||||
MatLegacyDialog as MatDialog,
|
||||
MatLegacyDialogRef as MatDialogRef,
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
} from '@angular/material/legacy-dialog';
|
||||
|
||||
import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
|
||||
import { mapTo } from 'rxjs';
|
||||
import { WarnDialogComponent } from 'src/app/modules/warn-dialog/warn-dialog.component';
|
||||
@ -17,35 +19,76 @@ import { ToastService } from 'src/app/services/toast.service';
|
||||
templateUrl: './add-action-dialog.component.html',
|
||||
styleUrls: ['./add-action-dialog.component.scss'],
|
||||
})
|
||||
export class AddActionDialogComponent {
|
||||
public name: string = '';
|
||||
public script: string = '';
|
||||
public durationInSec: number = 10;
|
||||
public allowedToFail: boolean = false;
|
||||
|
||||
export class AddActionDialogComponent implements OnInit {
|
||||
public id: string = '';
|
||||
|
||||
public opened$ = this.dialogRef.afterOpened().pipe(mapTo(true));
|
||||
|
||||
public form: FormGroup = new FormGroup({
|
||||
name: new FormControl<string>('', []),
|
||||
script: new FormControl<string>('', []),
|
||||
durationInSec: new FormControl<number>(10, []),
|
||||
allowedToFail: new FormControl<boolean>(false, []),
|
||||
});
|
||||
constructor(
|
||||
private toast: ToastService,
|
||||
private mgmtService: ManagementService,
|
||||
private dialog: MatDialog,
|
||||
private unsavedChangesDialog: MatDialog,
|
||||
public dialogRef: MatDialogRef<AddActionDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||
) {
|
||||
if (data && data.action) {
|
||||
const action: Action.AsObject = data.action;
|
||||
this.name = action.name;
|
||||
this.script = action.script;
|
||||
if (action.timeout?.seconds) {
|
||||
this.durationInSec = action.timeout?.seconds;
|
||||
}
|
||||
this.allowedToFail = action.allowedToFail;
|
||||
this.form.setValue({
|
||||
name: action.name,
|
||||
script: action.script,
|
||||
durationInSec: action.timeout?.seconds ?? 10,
|
||||
allowedToFail: action.allowedToFail,
|
||||
});
|
||||
this.id = action.id;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// prevent unsaved changes get lost if backdrop is clicked
|
||||
this.dialogRef.backdropClick().subscribe(() => {
|
||||
if (this.form.dirty) {
|
||||
this.showUnsavedDialog();
|
||||
} else {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
});
|
||||
|
||||
// prevent unsaved changes get lost if escape key is pressed
|
||||
this.dialogRef.keydownEvents().subscribe((event) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (this.form.dirty) {
|
||||
this.showUnsavedDialog();
|
||||
} else {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showUnsavedDialog(): void {
|
||||
const unsavedChangesDialogRef = this.unsavedChangesDialog.open(WarnDialogComponent, {
|
||||
data: {
|
||||
confirmKey: 'ACTIONS.UNSAVED.DIALOG.DISCARD',
|
||||
cancelKey: 'ACTIONS.UNSAVED.DIALOG.CANCEL',
|
||||
titleKey: 'ACTIONS.UNSAVEDCHANGES',
|
||||
descriptionKey: 'ACTIONS.UNSAVED.DIALOG.DESCRIPTION',
|
||||
},
|
||||
width: '400px',
|
||||
});
|
||||
|
||||
unsavedChangesDialogRef.afterClosed().subscribe((resp) => {
|
||||
if (resp) {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
@ -54,27 +97,27 @@ export class AddActionDialogComponent {
|
||||
if (this.id) {
|
||||
const req = new UpdateActionRequest();
|
||||
req.setId(this.id);
|
||||
req.setName(this.name);
|
||||
req.setScript(this.script);
|
||||
req.setName(this.form.value.name);
|
||||
req.setScript(this.form.value.script);
|
||||
|
||||
const duration = new Duration();
|
||||
duration.setNanos(0);
|
||||
duration.setSeconds(this.durationInSec);
|
||||
duration.setSeconds(this.form.value.durationInSec);
|
||||
|
||||
req.setAllowedToFail(this.allowedToFail);
|
||||
req.setAllowedToFail(this.form.value.allowedToFail);
|
||||
|
||||
req.setTimeout(duration);
|
||||
this.dialogRef.close(req);
|
||||
} else {
|
||||
const req = new CreateActionRequest();
|
||||
req.setName(this.name);
|
||||
req.setScript(this.script);
|
||||
req.setName(this.form.value.name);
|
||||
req.setScript(this.form.value.script);
|
||||
|
||||
const duration = new Duration();
|
||||
duration.setNanos(0);
|
||||
duration.setSeconds(this.durationInSec);
|
||||
duration.setSeconds(this.form.value.durationInSec);
|
||||
|
||||
req.setAllowedToFail(this.allowedToFail);
|
||||
req.setAllowedToFail(this.form.value.allowedToFail);
|
||||
|
||||
req.setTimeout(duration);
|
||||
this.dialogRef.close(req);
|
||||
|
@ -20,6 +20,7 @@ import { AdminService } from 'src/app/services/admin.service';
|
||||
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-org-create',
|
||||
@ -45,7 +46,7 @@ export class OrgCreateComponent {
|
||||
public pwdForm?: UntypedFormGroup;
|
||||
|
||||
public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
|
||||
public languages: string[] = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'];
|
||||
public languages: string[] = supportedLanguages;
|
||||
|
||||
public policy?: PasswordComplexityPolicy.AsObject;
|
||||
public usePassword: boolean = false;
|
||||
|
@ -378,7 +378,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
if (resp.clientId || resp.clientSecret) {
|
||||
this.showSavedDialog(resp);
|
||||
} else {
|
||||
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
|
||||
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -396,7 +396,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
if (resp.clientId || resp.clientSecret) {
|
||||
this.showSavedDialog(resp);
|
||||
} else {
|
||||
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
|
||||
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -410,7 +410,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
.addSAMLApp(this.samlAppRequest)
|
||||
.then((resp) => {
|
||||
this.loading = false;
|
||||
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
|
||||
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
|
||||
})
|
||||
.catch((error) => {
|
||||
this.loading = false;
|
||||
@ -436,7 +436,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
this.router.navigate(['projects', this.projectId, 'apps', added.appId]);
|
||||
this.router.navigate(['projects', this.projectId, 'apps', added.appId], { queryParams: { new: true } });
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -88,7 +88,7 @@ export class ProjectRoleCreateComponent implements OnInit, OnDestroy {
|
||||
.bulkAddProjectRoles(this.projectId, rolesToAdd)
|
||||
.then(() => {
|
||||
this.toast.showInfo('PROJECT.TOAST.ROLESCREATED', true);
|
||||
this.router.navigate(['projects', this.projectId], { queryParams: { id: 'roles' } });
|
||||
this.router.navigate(['projects', this.projectId], { queryParams: { id: 'roles', new: true } });
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
|
@ -33,7 +33,7 @@ export class ProjectCreateComponent {
|
||||
.addProject(this.project)
|
||||
.then((resp: AddProjectResponse.AsObject) => {
|
||||
this.toast.showInfo('PROJECT.TOAST.CREATED', true);
|
||||
this.router.navigate(['projects', resp.id]);
|
||||
this.router.navigate(['projects', resp.id], { queryParams: { new: true } });
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
|
@ -71,7 +71,7 @@ export class UserCreateMachineComponent implements OnDestroy {
|
||||
this.toast.showInfo('USER.TOAST.CREATED', true);
|
||||
const id = data.userId;
|
||||
if (id) {
|
||||
this.router.navigate(['users', id]);
|
||||
this.router.navigate(['users', id], { queryParams: { new: true } });
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
|
@ -13,6 +13,7 @@ import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
import { CountryCallingCodesService, CountryPhoneCode } from 'src/app/services/country-calling-codes.service';
|
||||
import { formatPhone } from 'src/app/utils/formatPhone';
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
import {
|
||||
containsLowerCaseValidator,
|
||||
containsNumberValidator,
|
||||
@ -33,7 +34,7 @@ import {
|
||||
export class UserCreateComponent implements OnInit, OnDestroy {
|
||||
public user: AddHumanUserRequest.AsObject = new AddHumanUserRequest().toObject();
|
||||
public genders: Gender[] = [Gender.GENDER_FEMALE, Gender.GENDER_MALE, Gender.GENDER_UNSPECIFIED];
|
||||
public languages: string[] = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'];
|
||||
public languages: string[] = supportedLanguages;
|
||||
public selected: CountryPhoneCode | undefined;
|
||||
public countryPhoneCodes: CountryPhoneCode[] = [];
|
||||
public userForm!: UntypedFormGroup;
|
||||
@ -183,7 +184,7 @@ export class UserCreateComponent implements OnInit, OnDestroy {
|
||||
.then((data) => {
|
||||
this.loading = false;
|
||||
this.toast.showInfo('USER.TOAST.CREATED', true);
|
||||
this.router.navigate(['users', data.userId]);
|
||||
this.router.navigate(['users', data.userId], { queryParams: { new: true } });
|
||||
})
|
||||
.catch((error) => {
|
||||
this.loading = false;
|
||||
|
@ -23,6 +23,7 @@ import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { formatPhone } from 'src/app/utils/formatPhone';
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
import { EditDialogComponent, EditDialogType } from './edit-dialog/edit-dialog.component';
|
||||
|
||||
@Component({
|
||||
@ -33,7 +34,7 @@ import { EditDialogComponent, EditDialogType } from './edit-dialog/edit-dialog.c
|
||||
export class AuthUserDetailComponent implements OnDestroy {
|
||||
public user?: User.AsObject;
|
||||
public genders: Gender[] = [Gender.GENDER_MALE, Gender.GENDER_FEMALE, Gender.GENDER_DIVERSE];
|
||||
public languages: string[] = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'];
|
||||
public languages: string[] = supportedLanguages;
|
||||
|
||||
private subscription: Subscription = new Subscription();
|
||||
|
||||
|
@ -22,6 +22,7 @@ import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
import { formatPhone } from 'src/app/utils/formatPhone';
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
import { EditDialogComponent, EditDialogType } from '../auth-user-detail/edit-dialog/edit-dialog.component';
|
||||
import { ResendEmailDialogComponent } from '../auth-user-detail/resend-email-dialog/resend-email-dialog.component';
|
||||
import { MachineSecretDialogComponent } from './machine-secret-dialog/machine-secret-dialog.component';
|
||||
@ -44,7 +45,7 @@ export class UserDetailComponent implements OnInit {
|
||||
public user!: User.AsObject;
|
||||
public metadata: Metadata.AsObject[] = [];
|
||||
public genders: Gender[] = [Gender.GENDER_MALE, Gender.GENDER_FEMALE, Gender.GENDER_DIVERSE];
|
||||
public languages: string[] = ['de', 'en', 'es', 'it', 'fr', 'ja', 'pl', 'zh'];
|
||||
public languages: string[] = supportedLanguages;
|
||||
|
||||
public ChangeType: any = ChangeType;
|
||||
|
||||
|
@ -170,14 +170,14 @@
|
||||
<ng-container matColumnDef="creationDate">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'USER.TABLE.CREATIONDATE' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null">
|
||||
<span class="no-break">{{ user.details.creationDate | timestampToDate | localizedDate : 'fromNow' }}</span>
|
||||
<span class="no-break">{{ user.details.creationDate | timestampToDate | localizedDate : 'regular' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="changeDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'USER.TABLE.CHANGEDATE' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let user" [routerLink]="user.id ? ['/users', user.id] : null">
|
||||
<span class="no-break">{{ user.details.changeDate | timestampToDate | localizedDate : 'fromNow' }}</span>
|
||||
<span class="no-break">{{ user.details.changeDate | timestampToDate | localizedDate : 'regular' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common';
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import * as moment from 'moment';
|
||||
import { supportedLanguages } from 'src/app/utils/language';
|
||||
|
||||
@Pipe({
|
||||
name: 'localizedDate',
|
||||
@ -17,16 +18,22 @@ export class LocalizedDatePipe implements PipeTransform {
|
||||
if (moment().diff(date, 'days') <= 2) {
|
||||
return date.fromNow(); // '2 days ago' etc.
|
||||
} else {
|
||||
const localeData = moment(value).localeData();
|
||||
const format = localeData.longDateFormat('L');
|
||||
return moment(value).format(`${format}, HH:mm`);
|
||||
return this.getDateInRegularFormat(value);
|
||||
}
|
||||
}
|
||||
if (pattern && pattern === 'regular') {
|
||||
moment.locale(this.translateService.currentLang ?? 'en');
|
||||
return this.getDateInRegularFormat(value);
|
||||
} else {
|
||||
const lang = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'].includes(this.translateService.currentLang)
|
||||
? this.translateService.currentLang
|
||||
: 'en';
|
||||
const lang = supportedLanguages.includes(this.translateService.currentLang) ? this.translateService.currentLang : 'en';
|
||||
const datePipe: DatePipe = new DatePipe(lang);
|
||||
return datePipe.transform(value, pattern ?? 'mediumDate');
|
||||
}
|
||||
}
|
||||
|
||||
private getDateInRegularFormat(value: any): string {
|
||||
const localeData = moment(value).localeData();
|
||||
const format = localeData.longDateFormat('L');
|
||||
return moment(value).format(`${format}, HH:mm`);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
export const supportedLanguages = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh'];
|
||||
export const supportedLanguagesRegexp: RegExp = /de|en|es|fr|it|ja|pl|zh/;
|
||||
export const supportedLanguages = ['de', 'en', 'es', 'fr', 'it', 'ja', 'pl', 'zh', 'bg'];
|
||||
export const supportedLanguagesRegexp: RegExp = /de|en|es|fr|it|ja|pl|zh|bg/;
|
||||
export const fallbackLanguage: string = 'en';
|
||||
|
2177
console/src/assets/i18n/bg.json
Normal file
2177
console/src/assets/i18n/bg.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -166,7 +166,14 @@
|
||||
"NEXT": "Weiter",
|
||||
"MORE": "mehr",
|
||||
"STEP": "Schritt",
|
||||
"COMINGSOON": "Coming soon",
|
||||
"UNSAVEDCHANGES": "Nicht gespeicherte Änderungen",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "Möchten Sie diese neue Aktion wirklich verwerfen? Ihre Aktion geht verloren",
|
||||
"CANCEL": "Abbrechen",
|
||||
"DISCARD": "Verwerfen"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Zeige Benutzer {{value}}"
|
||||
}
|
||||
@ -1032,7 +1039,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "SMTP Einstellungen",
|
||||
@ -1231,7 +1239,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "Email Verification erfolgreich",
|
||||
@ -2099,7 +2108,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "Manager hinzufügen",
|
||||
|
@ -167,7 +167,14 @@
|
||||
"MORE": "more",
|
||||
"STEP": "Step",
|
||||
"SETUP": "Setup",
|
||||
"COMINGSOON": "Coming soon",
|
||||
"UNSAVEDCHANGES": "Unsaved changes",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "Are you sure you want to discard this new action? Your action will be lost",
|
||||
"CANCEL": "Cancel",
|
||||
"DISCARD": "Discard"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Show user {{value}}"
|
||||
}
|
||||
@ -1033,7 +1040,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "SMTP Settings",
|
||||
@ -1232,7 +1240,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "Email verification done",
|
||||
@ -2096,7 +2105,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "Add a Manager",
|
||||
|
@ -167,7 +167,14 @@
|
||||
"MORE": "más",
|
||||
"STEP": "Paso",
|
||||
"SETUP": "Configurar",
|
||||
"COMINGSOON": "Próximamente",
|
||||
"UNSAVEDCHANGES": "Cambios no guardados",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "¿Estás seguro de que quieres descartar esta nueva acción? Tu acción se perderá",
|
||||
"CANCEL": "Cancelar",
|
||||
"DISCARD": "Descartar"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Mostrar usuario {{value}}"
|
||||
}
|
||||
@ -1033,7 +1040,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "Ajustes SMTP",
|
||||
@ -1232,7 +1240,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "Verificación de email realizada",
|
||||
@ -2096,7 +2105,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "Añadir un Mánager",
|
||||
|
@ -166,7 +166,14 @@
|
||||
"NEXT": "Suivant",
|
||||
"MORE": "plus",
|
||||
"STEP": "Étape",
|
||||
"COMINGSOON": "Coming soon",
|
||||
"UNSAVEDCHANGES": "Modifications non enregistrées",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "Voulez-vous vraiment annuler cette nouvelle action ? Votre action sera perdue",
|
||||
"CANCEL": "Annuler",
|
||||
"DISCARD": "Jeter"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Afficher l'utilisateur{{value}}"
|
||||
}
|
||||
@ -1032,7 +1039,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "Paramètres SMTP",
|
||||
@ -1231,7 +1239,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "Vérification de l'email effectuée",
|
||||
@ -2088,7 +2097,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "Ajouter un manager",
|
||||
|
@ -166,7 +166,13 @@
|
||||
"NEXT": "Avanti",
|
||||
"MORE": "azioni",
|
||||
"STEP": "Passo",
|
||||
"COMINGSOON": "Coming soon",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "Sei sicuro di voler eliminare questa nuova azione? La tua azione andrà persa",
|
||||
"CANCEL": "Cancella",
|
||||
"DISCARD": "Continua comunque"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Mostra utente {{value}}"
|
||||
}
|
||||
@ -1033,7 +1039,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "Impostazioni SMTP",
|
||||
@ -1232,7 +1239,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "Verifica dell'e-mail terminata con successo.",
|
||||
@ -2101,7 +2109,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "Aggiungi un manager",
|
||||
|
@ -167,7 +167,14 @@
|
||||
"MORE": "さらに",
|
||||
"STEP": "ステップ",
|
||||
"SETUP": "セットアップ",
|
||||
"COMINGSOON": "近日公開",
|
||||
"UNSAVEDCHANGES": "未保存の変更",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "この新しいアクションを破棄してもよろしいですか?あなたのアクションは失われます",
|
||||
"CANCEL": "キャンセル",
|
||||
"DISCARD": "破棄"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "ユーザー {{value}} を表示する"
|
||||
}
|
||||
@ -1033,7 +1040,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "SMTP設定",
|
||||
@ -1227,7 +1235,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "メール認証が完了しました",
|
||||
@ -2091,7 +2100,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "マネージャーを追加する",
|
||||
|
@ -166,7 +166,14 @@
|
||||
"NEXT": "Następny",
|
||||
"MORE": "więcej",
|
||||
"STEP": "Krok",
|
||||
"COMINGSOON": "Coming soon",
|
||||
"UNSAVEDCHANGES": "Niezapisane zmiany",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "Czy na pewno chcesz odrzucić to nowe działanie? Twoje działanie zostanie utracone",
|
||||
"CANCEL": "Anuluj",
|
||||
"DISCARD": "Wyrzucać"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Pokaż użytkownika {{value}}"
|
||||
}
|
||||
@ -1032,7 +1039,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "Ustawienia SMTP",
|
||||
@ -1231,7 +1239,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "Weryfikacja adresu e-mail zakończona",
|
||||
@ -2100,7 +2109,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "Dodaj managera",
|
||||
|
@ -165,8 +165,15 @@
|
||||
"PREVIOUS": "上一页",
|
||||
"NEXT": "下一页",
|
||||
"MORE": "更多",
|
||||
"STEP": "Step",
|
||||
"COMINGSOON": "Coming soon",
|
||||
"STEP": "步",
|
||||
"UNSAVEDCHANGES": "未保存的更改",
|
||||
"UNSAVED": {
|
||||
"DIALOG": {
|
||||
"DESCRIPTION": "您确定要放弃此新操作吗?你的行动将会失败",
|
||||
"CANCEL": "取消",
|
||||
"DISCARD": "丢弃"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"SHOWUSER": "Show user {{value}}"
|
||||
}
|
||||
@ -1032,7 +1039,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "SMTP 设置",
|
||||
@ -1230,7 +1238,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"KEYS": {
|
||||
"emailVerificationDoneText": "电子邮件验证完成",
|
||||
@ -2087,7 +2096,8 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"pl": "Polski",
|
||||
"zh": "简体中文"
|
||||
"zh": "简体中文",
|
||||
"bg": "Български"
|
||||
},
|
||||
"MEMBER": {
|
||||
"ADD": "添加管理者",
|
||||
|
20
docs/docs/guides/integrate/login-ui/_logout.mdx
Normal file
20
docs/docs/guides/integrate/login-ui/_logout.mdx
Normal file
@ -0,0 +1,20 @@
|
||||
When your user is done using your application and clicks on the logout button, you have to send a request to the terminate session endpoint.
|
||||
[Terminate Session Documentation](https://zitadel.com/docs/apis/resources/session_service/session-service-delete-session)
|
||||
|
||||
Send the session token in the body of the request.
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl --request DELETE \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/sessions/218480890961985793 \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"sessionToken": "blGKerGQPKv8jN21p6E9GB1B-vl6_EyKlvTd5UALu8-aQmjucgZxHSXJx3XMFTwT9_Y3VnbOo3gC_Q"
|
||||
}'
|
||||
```
|
||||
|
||||
Terminating a session means to delete it.
|
||||
If you try to read the session afterwards, you will get an error “Session does not exist”.
|
80
docs/docs/guides/integrate/login-ui/_select-account.mdx
Normal file
80
docs/docs/guides/integrate/login-ui/_select-account.mdx
Normal file
@ -0,0 +1,80 @@
|
||||
|
||||
If you want to build your own select account/account picker, you have to cache the session IDs.
|
||||
We recommend storing a list of the session Ids with the corresponding session token in a cookie.
|
||||
The list of session IDs can be sent in the “search sessions” request to get a detailed list of sessions for the account selection.
|
||||
|
||||
[Search Sessions Documentation](https://zitadel.com/docs/apis/resources/session_service/session-service-list-sessions)
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/sessions/_search \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"query": {
|
||||
"offset": "0",
|
||||
"limit": 100,
|
||||
"asc": true
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"idsQuery": {
|
||||
"ids": [
|
||||
"218380657934467329", "218480890961985793"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```bash
|
||||
{
|
||||
"details": {
|
||||
"totalResult": "2",
|
||||
"processedSequence": "582",
|
||||
"timestamp": "2023-06-14T05:42:11.644220Z"
|
||||
},
|
||||
"sessions": [
|
||||
{
|
||||
"id": "218380657934467329",
|
||||
"creationDate": "2023-06-13T12:56:56.683629Z",
|
||||
"changeDate": "2023-06-13T12:56:56.724450Z",
|
||||
"sequence": "574",
|
||||
"factors": {
|
||||
"user": {
|
||||
"verifiedAt": "2023-06-13T12:56:55.440850Z",
|
||||
"id": "218380659823356328",
|
||||
"loginName": "minnie-mouse@fabi.zitadel.app",
|
||||
"displayName": "Minnie Mouse"
|
||||
},
|
||||
"password": {
|
||||
"verifiedAt": "2023-06-13T12:56:56.675359Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "218480890961985793",
|
||||
"creationDate": "2023-06-14T05:32:38.977954Z",
|
||||
"changeDate": "2023-06-14T05:42:11.631901Z",
|
||||
"sequence": "582",
|
||||
"factors": {
|
||||
"user": {
|
||||
"verifiedAt": "2023-06-14T05:32:38.972712Z",
|
||||
"id": "218380659823356328",
|
||||
"loginName": "minnie-mouse@fabi.zitadel.app",
|
||||
"displayName": "Minnie Mouse"
|
||||
},
|
||||
"password": {
|
||||
"verifiedAt": "2023-06-14T05:42:11.619484Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
209
docs/docs/guides/integrate/login-ui/external-login.mdx
Normal file
209
docs/docs/guides/integrate/login-ui/external-login.mdx
Normal file
@ -0,0 +1,209 @@
|
||||
---
|
||||
title: Handle External Login
|
||||
sidebar_label: External Identity Provider
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||
The prerequisite for adding an external login (social and enterprise) to your user account is a registered identity provider on your ZITADEL instance or the organization of the user.
|
||||
If you haven’t added a provider yet, have a look at the following guide first: [Identity Providers](https://zitadel.com/docs/guides/integrate/identity-providers)
|
||||
|
||||

|
||||
|
||||
## Start the Provider Flow
|
||||
|
||||
ZITADEL will handle as much as possible from the authentication flow with the external provider.
|
||||
This requires you to initiate the flow with your desired provider.
|
||||
|
||||
Send the following two URLs in the request body:
|
||||
1. SuccessURL: Page that should be shown when the login was successful
|
||||
2. ErrorURL: Page that should be shown when an error happens during the authentication
|
||||
|
||||
In the response, you will get an authentication URL of the provider you like.
|
||||
[Start Identity Provider Flow Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-start-identity-provider-flow)
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/users/idps/$IDP_ID/start \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"successUrl": "https://custom.com/login/idp/success",
|
||||
"failureUrl": "https://custom.com/login/idp/fail"
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```bash
|
||||
{
|
||||
"details": {
|
||||
"sequence": "592",
|
||||
"changeDate": "2023-06-14T12:51:29.654819Z",
|
||||
"resourceOwner": "163840776835432705"
|
||||
},
|
||||
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=Test&prompt=select_account&redirect_uri=https%3A%2F%2F$ZITADEL_DOMAIN%2Fidps%2Fcallback&response_type=code&scope=openid+profile+email&state=218525066445455617"
|
||||
}
|
||||
```
|
||||
|
||||
## Call Provider
|
||||
|
||||
The next step is to call the auth URL you got in the response from the previous step.
|
||||
This will open up the login page of the given provider. In this guide, it is Google Login.
|
||||
|
||||
```bash
|
||||
https://accounts.google.com/o/oauth2/v2/auth?client_id=Test&prompt=select_account&redirect_uri=https%3A%2F%2F$ZITADEL_DOMAIN%2Fidps%2Fcallback&response_type=code&scope=openid+profile+email&state=218525066445455617
|
||||
```
|
||||
|
||||
After the user has successfully authenticated, a redirect to the ZITADEL backend /idps/callback will automatically be performed.
|
||||
|
||||
## Get Provider Information
|
||||
|
||||
ZITADEL will take the information of the provider. After this, a redirect will be made to either the success page in case of a successful login or to the error page in case of a failure will be performed. In the parameters, you will provide the intentID, a token, and optionally, if a user could be found, a user ID.
|
||||
|
||||
To get the information of the provider, make a request to ZITADEL.
|
||||
[Get Identity Provider Information Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-retrieve-identity-provider-information)
|
||||
|
||||
### Request
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/users/intents/$INTENT_ID/information \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"token": "k50WQmDaPIazQDJsyKaEPaQPwgsytxqgQ3K1ifQeQtAmeQ"
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```bash
|
||||
{
|
||||
"details": {
|
||||
"sequence": "599",
|
||||
"changeDate": "2023-06-15T06:44:26.039444Z",
|
||||
"resourceOwner": "163840776835432705"
|
||||
},
|
||||
"idpInformation": {
|
||||
"oauth": {
|
||||
"accessToken": "ya29.a0AWY7CknrOORopcJK2XX2fQXV9NQpp8JdkKYx-mQZNrR-wktWWhc3QsepT6kloSCgFPS9N0beEBlEYoY5oYUhfc7VlLHTQz5iECE386pyx5YmTueyeQ9GXk1dAw89gi8KRyjNlJApFsfLJaoiLIvKJLf23eAyXgaCgYKAUMSARESFQG1tDrpnTJ2su8BBO24zfmzgneARw0165",
|
||||
"idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijg1YmE5MzEzZmQ3YTdkNGFmYTg0ODg0YWJjYzg0MDMwMDQzNjMxODAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxODI5MDIwMjY1MDgtdW1taXQ3dHZjbHBnM2NxZmM4b2ljdGE1czI1aGtudWwuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiIxODI5MDIwMjY1MDgtdW1taXQ3dHZjbHBnM2NxZmM4b2ljdGE1czI1aGtudWwuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTEzOTI4MDU5NzU3MTU4NTY2MzciLCJoZCI6InJvb3RkLmNoIiwiZW1haWwiOiJmYWJpZW5uZUByb290ZC5jaCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoidGN5X25JTkZHNnFhRTBZTWFsQzZGdyIsIm5hbWUiOiJGYWJpZW5uZSBCw7xobGVyIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hL0FBY0hUdGY5NzNRNk5IOEt6S1RNRVpFTFBVOWx4NDVXcFE5RlJCdXhGZFBiPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IkZhYmllbm5lIiwiZmFtaWx5X25hbWUiOiJCw7xobGVyIiwibG9jYWxlIjoiZGUiLCJpYXQiOjE2ODY4MTE0NjUsImV4cCI6MTY4NjgxNTA2NX0.PwlAHRM44e8eYyHzdfotOrcq5GZc4D15mWvN3rGdoDmu2RRgb4T0nDgkY6AVq2vNJxPfbiB1jFtNP6dgX-OgLIxNXg_tJJhwFh-eFPA37cIiv1CEcgEC-q1zXFIa3HrwHLreeU6i7C9JkDrKpkS-AKat1krf27QXxrxHLrWehi5F2l1OZuAKFWYaYmJOd0sVTDBA2o5SDcAiQs_D4-Q-kSl5f0gh607YVHLv7zjyfHtAOs7xPEkNEUVcqGBke2Zy9kAYIgiMriNxLA2EDxFtSyWnf-bCXKnuVX2hwEY0T0lUPrOAVkz7MEOKiacE2xAOczoQvl-wECU0UofLi8XZqg"
|
||||
},
|
||||
"idpId": "218528353504723201",
|
||||
"rawInformation": {
|
||||
"User": {
|
||||
"email": "fabienne@rootd.ch",
|
||||
"email_verified": true,
|
||||
"family_name": "Bühler",
|
||||
"given_name": "Fabienne",
|
||||
"hd": "rootd.ch",
|
||||
"locale": "de",
|
||||
"name": "Fabienne Bühler",
|
||||
"picture": "https://lh3.googleusercontent.com/a/AAcKTtf973Q6NH8KzKTMEZELPU9lx45WpQ9FRBuxFdPb=s96-c",
|
||||
"sub": "111392805975715856637"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Handle Provider Information
|
||||
After successfully authenticating using your identity provider, you have three possible options.
|
||||
1. Login
|
||||
2. Register user
|
||||
3. Add social login to existing user
|
||||
|
||||
### Login
|
||||
|
||||
If you did get a user ID in the parameters when calling your success page, you know that a user is already linked with the used identity provider and you are ready to perform the login.
|
||||
Create a new session and include the intent ID and the token in the checks.
|
||||
This check requires that the previous step ended on the successful page and didn't’t result in an error.
|
||||
|
||||
#### Request
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/sessions \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"checks": {
|
||||
"user": {
|
||||
"userId": "218662596918640897"
|
||||
},
|
||||
"intent": {
|
||||
"intentId": "219647325729980673",
|
||||
"token": "k86ihn-VLMMUGKy1q1b5i_foECspKYqei1l4mS8LT7Xzjw"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Register
|
||||
|
||||
If you didn't get a user ID in the parameters of your success page, you know that there is no existing user in ZITADEL with that provider, and you can register a new user or link it to an existing account (read the next section).
|
||||
|
||||
Fill the IdP links in the create user request to add a user with an external login provider.
|
||||
The idpId is the ID of the provider in ZITADEL, the idpExternalId is the ID of the user in the external identity provider; usually, this is sent in the “sub”.
|
||||
The display name is used to list the linkings on the users.
|
||||
|
||||
[Create User API Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-add-human-user)
|
||||
|
||||
#### Request
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/users/human \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"username": "minni-mouse@mouse.com",
|
||||
"profile": {
|
||||
"firstName": "Minnie",
|
||||
"lastName": "Mouse",
|
||||
"nickName": "Mini",
|
||||
"displayName": "Minnie Mouse",
|
||||
"preferredLanguage": "en",
|
||||
"gender": "GENDER_FEMALE"
|
||||
},
|
||||
"email": {
|
||||
"email": "minni-mouse@mouse.com",
|
||||
"isVerified": true
|
||||
},
|
||||
"idpLinks": [
|
||||
{
|
||||
"idpId": "218528353504723201",
|
||||
"idpExternalId": "111392805975715856637",
|
||||
"displayName": "Minnie Mouse"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Add External Login to Existing User
|
||||
|
||||
If you didn't get a user ID in the parameters to your success page, you know that there is no existing user in ZITADEL with that provider and you can register a new user (read previous section), or link it to an existing account.
|
||||
|
||||
If you want to link/connect to an existing account you can perform the add identity provider link request.
|
||||
[Add IDP Link to existing user documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-add-idp-link)
|
||||
|
||||
#### Request
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/users/users/218385419895570689/links \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"idpLink": {
|
||||
"idpId": "218528353504723201",
|
||||
"idpExternalId": "1113928059757158566371",
|
||||
"displayName": "Minnie Mouse"
|
||||
}
|
||||
}'
|
||||
```
|
7
docs/docs/guides/integrate/login-ui/logout.mdx
Normal file
7
docs/docs/guides/integrate/login-ui/logout.mdx
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Logout
|
||||
---
|
||||
|
||||
import Logout from './_logout.mdx';
|
||||
|
||||
<Logout/>
|
26
docs/docs/guides/integrate/login-ui/oidc-standard.mdx
Normal file
26
docs/docs/guides/integrate/login-ui/oidc-standard.mdx
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
title: OIDC Standard
|
||||
---
|
||||
|
||||
:::info
|
||||
Not yet implemented, but should give you a general impression of how it will work
|
||||
Subscribe to the following issue: https://github.com/orgs/zitadel/projects/2/views/1?filterQuery=oidc&pane=issue&itemId=23181369
|
||||
:::
|
||||
|
||||
To build your own login ui for your own application it is not necessary to have the OIDC standard included or any additional work that has to be done.
|
||||
However, it might make sense, if you want to connect your login to different applications especially if they are not in your control and they rely on the standard.
|
||||
|
||||
The following flow shows you the different components you need to enable OIDC for your login.
|
||||

|
||||
|
||||
1. Your application makes an authorization request to your login UI
|
||||
2. The login UI takes the requests and sends them to the ZITADEL API. In the request to the ZITADEL API, a header to authenticate your client is needed.
|
||||
3. The ZITADEL API parses the request and does what it needs to interpret certain parameters (e.g., organization scope, etc.)
|
||||
4. Redirect to a predefined, relative URL of the login UI that includes the authrequest ID
|
||||
5. Request to ZITADEL API to get all the information from the auth request
|
||||
6. Create and update the session till the login flow is complete and the user is authenticated. Make sure to include the auth Request ID in the session
|
||||
7. Read the callback URL from the ZITADEL API
|
||||
8. Redirect to your application with the callback URL you got in the previous request
|
||||
9. All OIDC-specific endpoints have to be accepted in the Login UI and should be proxied and sent to the ZITADEL API
|
||||
|
||||
|
7
docs/docs/guides/integrate/login-ui/select-account.mdx
Normal file
7
docs/docs/guides/integrate/login-ui/select-account.mdx
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Select Account
|
||||
---
|
||||
|
||||
import SelectAccount from './_select-account.mdx';
|
||||
|
||||
<SelectAccount/>
|
236
docs/docs/guides/integrate/login-ui/username-password.mdx
Normal file
236
docs/docs/guides/integrate/login-ui/username-password.mdx
Normal file
@ -0,0 +1,236 @@
|
||||
---
|
||||
title: Register and Login User with Password
|
||||
sidebar_label: Username and Password
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||

|
||||
|
||||
## Register
|
||||
|
||||
First, we create a new user with a username and password. In the example below we add a user with profile data, a verified email address, and a password.
|
||||
[Create User Documentation](https://zitadel.com/docs/apis/resources/user_service/user-service-add-human-user)
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/users/human \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"'' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"userId": "d654e6ba-70a3-48ef-a95d-37c8d8a7901a",
|
||||
"username": "minnie-mouse",
|
||||
"profile": {
|
||||
"firstName": "Minnie",
|
||||
"lastName": "Mouse",
|
||||
"nickName": "Mini",
|
||||
"displayName": "Minnie Mouse",
|
||||
"preferredLanguage": "en",
|
||||
"gender": "GENDER_FEMALE"
|
||||
},
|
||||
"email": {
|
||||
"email": "mini@mouse.com",
|
||||
"isVerified": true
|
||||
},
|
||||
"metadata": [
|
||||
{
|
||||
"key": "my-key",
|
||||
"value": "VGhpcyBpcyBteSB0ZXN0IHZhbHVl"
|
||||
}
|
||||
],
|
||||
"password": {
|
||||
"password": "Secr3tP4ssw0rd!",
|
||||
"changeRequired": false
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```bash
|
||||
{
|
||||
"userId": "d654e6ba-70a3-48ef-a95d-37c8d8a7901a",
|
||||
"details": {
|
||||
"sequence": "570",
|
||||
"changeDate": "2023-06-13T12:44:36.967851Z",
|
||||
"resourceOwner": "163840776835432705"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you want the user to verify the email address you can either choose that ZITADEL sends a verification email to the user by adding a urlTemplate into the sendCode, or ask for a return code to send your own emails.
|
||||
|
||||
Send Email by ZITADEL:
|
||||
|
||||
```bash
|
||||
"sendCode": {
|
||||
"urlTemplate": "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"
|
||||
},
|
||||
```
|
||||
|
||||
Return Code:
|
||||
```bash
|
||||
"email": {
|
||||
"email": "mini@mouse.com",
|
||||
"returnCode": {}
|
||||
},
|
||||
```
|
||||
|
||||
To check what is allowed on your instance, call the settings service for more information.
|
||||
The following requests can be useful for registration:
|
||||
- [Get Login Settings](https://zitadel.com/docs/apis/resources/settings_service/settings-service-get-login-settings) To find out which authentication possibilities are enabled (password, identity provider, etc.)
|
||||
- [Get Password Complexity Settings](https://zitadel.com/docs/apis/resources/settings_service/settings-service-get-password-complexity-settings) to find out how the password should look like (length, characters, etc.)
|
||||
|
||||
## Create Session with User Check
|
||||
|
||||
After you have created a new user, you can redirect him to your login screen.
|
||||
You can either create a new empty session as soon as the first login screen is shown or update it with each piece of information you get throughout the process.
|
||||
Or you can create a new session with the first credentials a user enters.
|
||||
In the following example, we assume that the login flow asks for the username in the first step, and in the second for the password.
|
||||
In API requests, this means creating a new session with a username and updating it with the password.
|
||||
If you already have the userId from a previous register, you can send it directly to skip the username and show the password screen directly.
|
||||
The create and update session endpoints will always return a session ID and an opaque session token.
|
||||
If you do not rely on the OIDC standard you can directly use the token.
|
||||
Send it to the Get Session Endpoint to find out how the user has authenticated.
|
||||
|
||||
- [Create new session Documentation](https://zitadel.com/docs/apis/resources/session_service/session-service-create-session)
|
||||
- [Update an existing session Documentation](https://zitadel.com/docs/apis/resources/session_service/session-service-set-session)
|
||||
- [Get Session Documentation](https://zitadel.com/docs/apis/resources/session_service/session-service-get-session)
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/sessions \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"'' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"checks": {
|
||||
"user": {
|
||||
"loginName": "minnie-mouse@fabi.zitadel.app"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```bash
|
||||
{
|
||||
"details": {
|
||||
"sequence": "580",
|
||||
"changeDate": "2023-06-14T05:32:39.007096Z",
|
||||
"resourceOwner": "163840776835432705"
|
||||
},
|
||||
"sessionId": "218480890961985793",
|
||||
"sessionToken": "yMDi6uVPJAcphbbz0LaxC07ihWkNTe7m0Xqch8SzfM5Cz3HSIQIDZ65x1f5Qal0jxz0MEyo-_zYcUg"
|
||||
}
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
If you read the newly created session, it will look like the following.
|
||||
You can see the creation and change date.
|
||||
In the factors, you will see all the checks that have been made.
|
||||
In this case, the user has been checked.
|
||||
|
||||
```bash
|
||||
{
|
||||
"session": {
|
||||
"id": "218480890961985793",
|
||||
"creationDate": "2023-06-14T05:32:38.977954Z",
|
||||
"changeDate": "2023-06-14T05:32:39.007096Z",
|
||||
"sequence": "580",
|
||||
"factors": {
|
||||
"user": {
|
||||
"verifiedAt": "2023-06-14T05:32:38.972712Z",
|
||||
"id": "d654e6ba-70a3-48ef-a95d-37c8d8a7901a",
|
||||
"loginName": "minnie-mouse@fabi.zitadel.app",
|
||||
"displayName": "Minnie Mouse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Update Session with Password
|
||||
|
||||
Your session already has a username check.
|
||||
The next step is to check the password.
|
||||
To update an existing session, add the session ID to the URL and the session token you got in the previous step to the request body.
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl --request PATCH \
|
||||
--url https://$ZITADEL_DOMAIN/v2alpha/sessions/$SESSION_ID \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Authorization: Bearer '"$TOKEN"''\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"sessionToken": "yMDi6uVPJAcphbbz0LaxC07ihWkNTe7m0Xqch8SzfM5Cz3HSIQIDZ65x1f5Qal0jxz0MEyo-_zYcUg",
|
||||
"checks": {
|
||||
"password": {
|
||||
"password": "Secr3tP4ssw0rd!"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The response of the create and update session token looks the same.
|
||||
Make sure to always use the session token of the last response you got, as the values may be updated.
|
||||
|
||||
```bash
|
||||
{
|
||||
"details": {
|
||||
"sequence": "582",
|
||||
"changeDate": "2023-06-14T05:42:11.631901Z",
|
||||
"resourceOwner": "163840776835432705"
|
||||
},
|
||||
"sessionToken": "blGKerGQPKv8jN21p6E9GB1B-vl6_EyKlvTd5UALu8-aQmjucgZxHSXJx3XMFTwT9_Y3VnbOo3gC_Q"
|
||||
}
|
||||
```
|
||||
|
||||
### Session State
|
||||
If you read your session after the password check, you will see that the check has been added to the factors with the verification date.
|
||||
|
||||
```bash
|
||||
{
|
||||
"session": {
|
||||
"id": "218480890961985793",
|
||||
"creationDate": "2023-06-14T05:32:38.977954Z",
|
||||
"changeDate": "2023-06-14T05:42:11.631901Z",
|
||||
"sequence": "582",
|
||||
"factors": {
|
||||
"user": {
|
||||
"verifiedAt": "2023-06-14T05:32:38.972712Z",
|
||||
"id": "d654e6ba-70a3-48ef-a95d-37c8d8a7901a",
|
||||
"loginName": "minnie-mouse@fabi.zitadel.app",
|
||||
"displayName": "Minnie Mouse"
|
||||
},
|
||||
"password": {
|
||||
"verifiedAt": "2023-06-14T05:42:11.619484Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## List the Sessions (Account Chooser)
|
||||
|
||||
import SelectAccount from './_select-account.mdx';
|
||||
|
||||
<SelectAccount/>
|
||||
|
||||
## Logout User
|
||||
|
||||
import Logout from './_logout.mdx';
|
||||
|
||||
<Logout/>
|
@ -42,6 +42,7 @@ ZITADEL is available in the following languages
|
||||
- 日本語 (ja)
|
||||
- Polish(pl)
|
||||
- 简体中文(zh)
|
||||
- Bulgarian (bg)
|
||||
|
||||
A language is displayed based on your agent's language header. The default language is English.
|
||||
|
||||
|
@ -5,158 +5,57 @@ title: User Metadata
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
In this guide you will learn how to manually create the necessary requests to authenticate and request a user's metadata from ZITADEL.
|
||||
This guide shows you how to request metadata from a user.
|
||||
ZITADEL offers multiple methods to retrieve metadata.
|
||||
Pick the one that works best for your solution.
|
||||
|
||||
Typical examples for user metadata include:
|
||||
## Use cases for metadata
|
||||
|
||||
Typical use cases for user metadata include:
|
||||
|
||||
- Link the user to an internal identifier (eg, userId, contract number, etc.)
|
||||
- Save custom user data when registering a user
|
||||
- Route upstream traffic based on user attributes
|
||||
|
||||
## Prerequisites
|
||||
## Before you start
|
||||
|
||||
### Create a new client
|
||||
Before you start you need to add some metadata to an existing user.
|
||||
You can do so by using [Console](../console/users) or [setting user metadata](/docs/apis/resources/mgmt/management-service-set-user-metadata) through the management API.
|
||||
|
||||
- Create a new [web application](../console/applications#web)
|
||||
- Use Code-Flow
|
||||
- In this example we will use `http://localhost` as redirect url
|
||||
- Make sure to note the client secret
|
||||
Most of the methods below require you to login with the correct user while setting some scopes.
|
||||
Make sure you pick the right user when logging into the test application.
|
||||
Use the [OIDC authentication request playground](/docs/apis/openidoauth/authrequest) or the configuration of an [example client](/docs/examples/introduction) to set the required scopes and receive a valid access token.
|
||||
|
||||
### Add metadata to a user
|
||||
|
||||
- [Add metadata](/guides/manage/customize/user-metadata) to a user
|
||||
- Make sure you will use this user to login during later steps
|
||||
|
||||
## Requesting a token
|
||||
|
||||
:::info
|
||||
In this guide we will manually request a token from ZITADEL for demonstration purposes. You will likely use a client library for the OpenID Authentication.
|
||||
:::info Getting a token
|
||||
In case you want to test out different settings configure an application with code flow (PKCE).
|
||||
Grab the code from the url parameter after a successful login and exchange the code for tokens by calling the [token endpoint](/docs/apis/openidoauth/endpoints#token_endpoint).
|
||||
You will find more information in our guides on how to [authenticate users](/docs/guides/integrate/login-users).
|
||||
:::
|
||||
|
||||
### Set environment variables
|
||||
## Use tokens to get user metadata
|
||||
|
||||
We will use some information throughout this guide. Set the required environment variables as follows. Make sure to replace the values with your information.
|
||||
Use one of these methods to get the metadata for the currently logged in user.
|
||||
|
||||
```bash
|
||||
export CLIENT_SECRET=QCiMffalakI...zpT0vuOsSkVk1ne \
|
||||
export CLIENT_ID="16604...@docs-claims" \
|
||||
export REDIRECT_URI="http://localhost" \
|
||||
export ZITADEL_DOMAIN="https://...asd.zitadel.cloud"
|
||||
```
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="go" label="Go" default>
|
||||
|
||||
Grab zitadel-tools to create the [required string](/apis/openidoauth/authn-methods#client-secret-basic) for Basic authentication:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:zitadel/zitadel-tools.git
|
||||
cd zitadel-tools/cmd/basicauth
|
||||
export BASIC_AUTH="$(go run basicauth.go -id $CLIENT_ID -secret $CLIENT_SECRET)"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```python
|
||||
import base64
|
||||
import urllib.parse
|
||||
import os
|
||||
|
||||
clientId = os.environ.get("CLIENT_ID")
|
||||
clientSecret = os.environ.get("CLIENT_SECRET")
|
||||
|
||||
escaped = safe_string = urllib.parse.quote_plus(clientId) + ":" + urllib.parse.quote_plus(clientSecret)
|
||||
message_bytes = escaped.encode('ascii')
|
||||
base64_bytes = base64.b64encode(message_bytes)
|
||||
base64_message = base64_bytes.decode('ascii')
|
||||
|
||||
print(base64_message)
|
||||
```
|
||||
|
||||
Export the result to the environment variable `BASIC_AUTH`.
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="js" label="Javascript" default>
|
||||
|
||||
```javascript
|
||||
esc = encodeURIComponent(process.env.CLIENT_ID) + ":" + encodeURIComponent(process.env.CLIENT_SECRET)
|
||||
enc = btoa(esc)
|
||||
console.log(enc)
|
||||
```
|
||||
|
||||
Export the result to the environment variable `BASIC_AUTH`.
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="manually" label="Manually">
|
||||
|
||||
You need to create a string as described [here](/apis/openidoauth/authn-methods#client-secret-basic).
|
||||
|
||||
Use a programming language of your choice or manually create the strings with online tools (don't use these secrets for production) like:
|
||||
|
||||
- https://www.urlencoder.org/
|
||||
- https://www.base64encode.org/
|
||||
|
||||
Export the result to the environment variable `BASIC_AUTH`.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Create Auth Request
|
||||
|
||||
You need to create a valid auth request, including the reserved scope `urn:zitadel:iam:user:metadata`. Please refer to our API documentation for more information about [reserved scopes](/apis/openidoauth/scopes#reserved-scopes) or try it out in our [OIDC Authrequest Playground](/apis/openidoauth/authrequest?scope=openid%20email%20profile%20urn%3Azitadel%3Aiam%3Auser%3Ametadata).
|
||||
|
||||
Login with the user to which you have added the metadata. After the login you will be redirected.
|
||||
|
||||
Grab the code paramter from the url (disregard the &code= parameter) and export the code as environment variable:
|
||||
|
||||
```bash
|
||||
export AUTH_CODE="Y6nWsgR5WB...zUtFqSp5Xw"
|
||||
```
|
||||
|
||||
### Token Request
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url "${ZITADEL_DOMAIN}/oauth/v2/token" \
|
||||
--header "Accept: application/json" \
|
||||
--header "Authorization: Basic ${BASIC_AUTH}" \
|
||||
--header 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data grant_type=authorization_code \
|
||||
--data-urlencode "code=$AUTH_CODE" \
|
||||
--data-urlencode "redirect_uri=$REDIRECT_URI"
|
||||
```
|
||||
|
||||
The result will give you something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token":"jZuRixKQTVecEjKqw...kc3G4",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":43199,
|
||||
"id_token":"ey...Ww"
|
||||
}
|
||||
```
|
||||
|
||||
Grab the access_token value and export as an environment variable:
|
||||
|
||||
```bash
|
||||
export ACCESS_TOKEN="jZuRixKQTVecEjKqw...kc3G4"
|
||||
```
|
||||
In case you want to manage metadata for other users than the currently logged in user, then you must use the [management service](#manage-user-metadata-through-the-management-api).
|
||||
|
||||
### Request metadata from userinfo endpoint
|
||||
|
||||
With the access token we can make a request to the userinfo endpoint to get the user's metadata. This method is the preferred method to retrieve a user's information in combination with opaque tokens, to insure that the token is valid.
|
||||
With the access token we can make a request to the [userinfo endpoint](/docs/apis/openidoauth/endpoints#introspection_endpoint) to get the user's metadata.
|
||||
This method is the preferred method to retrieve a user's information in combination with opaque tokens, to insure that the token is valid.
|
||||
|
||||
You must pass the [reserved scope](/docs/apis/openidoauth/scopes#reserved-scopes) `urn:zitadel:iam:user:metadata` in your authentication request.
|
||||
If you don't include this scope the response will contain user data, but not the metadata object.
|
||||
|
||||
Request the user information by calling the [userinfo endpoint](/docs/apis/openidoauth/endpoints#introspection_endpoint):
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url "${ZITADEL_DOMAIN}/oidc/v1/userinfo" \
|
||||
--url "https://$ZITADEL_DOMAIN/oidc/v1/userinfo" \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
Replace `$ACCESS_TOKEN` with your user's access token.
|
||||
|
||||
The response will look something like this
|
||||
|
||||
```json
|
||||
@ -178,15 +77,19 @@ The response will look something like this
|
||||
}
|
||||
```
|
||||
|
||||
You can grab the metadata from the reserved claim `"urn:zitadel:iam:user:metadata"` as key-value pairs. Note that the values are base64 encoded. So the value `MTIzNA` decodes to `1234`.
|
||||
You can grab the metadata from the reserved claim `"urn:zitadel:iam:user:metadata"` as key-value pairs.
|
||||
Note that the values are base64 encoded.
|
||||
So the value `MTIzNA` decodes to `1234`.
|
||||
|
||||
### Send metadata inside the ID token (optional)
|
||||
### Send metadata inside the ID token
|
||||
|
||||
Check "User Info inside ID Token" in the configuration of your application.
|
||||
You might want to include metadata directly into the ID Token.
|
||||
For that you need to enable "User Info inside ID Token" in your application's settings.
|
||||
|
||||

|
||||
|
||||
Now request a new token from ZITADEL.
|
||||
Now request a new token from ZITADEL by logging in with the user that has metadata attached.
|
||||
Make sure you log into the correct client/application where you enabled the settings.
|
||||
|
||||
The result will give you something like:
|
||||
|
||||
@ -199,4 +102,129 @@ The result will give you something like:
|
||||
}
|
||||
```
|
||||
|
||||
Grab the id_token and inspect the contents of the token at [jwt.io](https://jwt.io/). You should get the same info in the ID token as when requested from the user endpoint.
|
||||
When you decode the value of `id_token`, then the response will include the metadata claim:
|
||||
|
||||
```json
|
||||
{
|
||||
"amr": [
|
||||
"password",
|
||||
"pwd",
|
||||
"mfa",
|
||||
"otp"
|
||||
],
|
||||
"at_hash": "lGIblkTr8faHz2zd0oTddA",
|
||||
"aud": [
|
||||
"170086824411201793@portal",
|
||||
"209806276543185153@portal",
|
||||
"170086774599581953"
|
||||
],
|
||||
"auth_time": 1687418556,
|
||||
"azp": "170086824411201793@portal",
|
||||
"c_hash": "dA3wre4ytCJCn11f7cIm0A",
|
||||
"client_id": "1700...1793@portal",
|
||||
"email": "road.runner@zitadel.com",
|
||||
"email_verified": true,
|
||||
"exp": 1687422272,
|
||||
"family_name": "Runner",
|
||||
"given_name": "Road",
|
||||
"iat": 1687418672,
|
||||
"iss": "https://...-abcd.zitadel.cloud",
|
||||
"locale": null,
|
||||
"name": "Road Runner",
|
||||
"preferred_username": "road.runner@...-abcd.zitadel.cloud",
|
||||
"sub": "170848145649959169",
|
||||
"updated_at": 1658329554,
|
||||
//highlight-start
|
||||
"urn:zitadel:iam:user:metadata": {
|
||||
"ContractNumber": "MTIzNA"
|
||||
}
|
||||
//highlight-end
|
||||
}
|
||||
```
|
||||
|
||||
Note that the values are base64 encoded.
|
||||
So the value `MTIzNA` decodes to `1234`.
|
||||
|
||||
:::info decoding the jwt token
|
||||
Use a website like [jwt.io](https://jwt.io/) to decode the token.
|
||||
With jq installed you can also use `jq -R 'split(".") | .[1] | @base64d | fromjson' <<< $ID_TOKEN`
|
||||
:::
|
||||
|
||||
### Request metadata from authentication API
|
||||
|
||||
You can use the authentication service to request and search for the user's metadata.
|
||||
|
||||
The introspection endpoint and the token endpoint in the examples above do not require a special scope to access.
|
||||
Yet when accessing the authentication service, you need to pass the [reserved scope](/docs/apis/openidoauth/scopes#reserved-scopes) `urn:zitadel:iam:org:project:id:zitadel:aud` along with the authentication request.
|
||||
This scope allows the user to access ZITADEL's APIs, specifically the authentication API that we need for this method.
|
||||
Use the [OIDC authentication request playground](/docs/apis/openidoauth/authrequest) or the configuration of an [example client](/docs/examples/introduction) to set the required scopes and receive a valid access token.
|
||||
|
||||
:::note Invalid audience
|
||||
If you get the error "invalid audience (APP-Zxfako)", then you need to add the reserved scope `urn:zitadel:iam:org:project:id:zitadel:aud` to your authentication request.
|
||||
:::
|
||||
|
||||
You can request the user's metadata with the [List My Metadata](/docs/apis/resources/auth/auth-service-list-my-metadata) method:
|
||||
|
||||
```bash
|
||||
curl -L -X POST "https://$ZITADEL_DOMAIN/auth/v1/users/me/metadata/_search" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
--data-raw '{
|
||||
"query": {
|
||||
"offset": "0",
|
||||
"limit": 100,
|
||||
"asc": true
|
||||
},
|
||||
"queries": [
|
||||
{
|
||||
"keyQuery": {
|
||||
"key": "$METADATA_KEY",
|
||||
"method": "TEXT_QUERY_METHOD_EQUALS"
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Replace `$ACCESS_TOKEN` with your user's access token.
|
||||
Replace `$ZITADEL_DOMAIN` with your ZITADEL instance's url.
|
||||
Replace `$METADATA_KEY` with they key you want to search for (f.e. "ContractNumber")
|
||||
|
||||
:::info Get all metadata
|
||||
You can omit the queries array to retrieve all metadata key-value pairs.
|
||||
:::
|
||||
|
||||
An example response for your search looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"details":{
|
||||
"totalResult":"1",
|
||||
"processedSequence":"2935",
|
||||
"viewTimestamp":"2023-06-21T16:01:52.829838Z"
|
||||
},
|
||||
"result":[
|
||||
{
|
||||
"details":{
|
||||
"sequence":"409",
|
||||
"creationDate":"2022-08-04T09:09:06.259324Z",
|
||||
"changeDate":"2022-08-04T09:09:06.259324Z",
|
||||
"resourceOwner":"170086363054473473"
|
||||
},
|
||||
"key":"ContractNumber",
|
||||
"value":"MTIzNA"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Manage user metadata through the management API
|
||||
|
||||
The previous methods allowed you to retrieve metadata only for the `sub` in the access token.
|
||||
In case you want to get the metadata for another user, you need to use the management service.
|
||||
The user that calls the management service must have [manager permissions](/docs/guides/manage/console/managers).
|
||||
A user can be either a human user or a service user.
|
||||
|
||||
You can get [metadata of a user filtered by your query](/docs/apis/resources/mgmt/management-service-list-user-metadata) or [get a metadata object from a user by a specific key](/docs/apis/resources/mgmt/management-service-get-user-metadata).
|
||||
The management service allows you to set and delete metadata, see the [API documentation for users](/docs/category/apis/resources/mgmt/users).
|
||||
|
@ -10,7 +10,7 @@ When moving from a previous auth solution to ZITADEL, it is important to note th
|
||||
Without duplicating too much content here are some important features and patterns to consider in terms of solution architecture.
|
||||
You can read more about the basic structure and important concepts of ZITADEL in our [concepts section](/docs/concepts/).
|
||||
|
||||
## Multi-Tenancy Architecture
|
||||
## Multi-tenancy architecture
|
||||
|
||||
Multi-tenancy in ZITADEL can be achieved through either [Instances](/docs/concepts/structure/instance) or [Organizations](/docs/concepts/structure/organizations).
|
||||
Where instances represent isolated ZITADEL instances, Organizations provide a more permeable approach to multi-tenancy.
|
||||
@ -19,7 +19,7 @@ In most cases, when you want to achieve multi-tenancy, you use Organizations. Ea
|
||||
Please also consult our guide on [Solution Scenarios](/docs/guides/solution-scenarios/introduction
|
||||
) for B2C and B2B for more details.
|
||||
|
||||
## Delegated Access Management
|
||||
## Delegated access management
|
||||
|
||||
Some solutions, that offer multi-tenancy, require you to copy applications and settings to each tenant and manage changes individually.
|
||||
ZITADEL works differently by using [Granted Projects](/docs/concepts/structure/granted_projects).
|
||||
@ -47,17 +47,24 @@ You can store arbitrary key-value pairs of data on objects such as Users or Orga
|
||||
Metadata could link a user to a specific backend user-id or represent an "organizational unit" for your business logic.
|
||||
Metadata can be access directly with the correct [scopes](/docs/apis/openidoauth/scopes#reserved-scopes) or transformed to custom claims (see above).
|
||||
|
||||
## Migrating users
|
||||
## Migrating resources
|
||||
|
||||
### Migrating users
|
||||
|
||||
Migrating users with minimal impact on users can be a challenging task.
|
||||
We provide some more information on migrating users and secrets in [this guide](./users.md).
|
||||
|
||||
### Migrating clients / applications
|
||||
|
||||
After you have set up or imported your applications to ZITADEL, you need to update your client's configurations, such as issuer, clientID or credentials.
|
||||
It is not possible to create an application with a pre-defined clientID or import existing credentials.
|
||||
|
||||
## Technical considerations
|
||||
|
||||
### Batch migration
|
||||
|
||||
**Batch migration** is the easiest way, if you can afford some minimal downtime to move all users and applications over to ZITADEL.
|
||||
See the [User guide](./users.md) for batch migration of users.
|
||||
See the [User guide](./users.md) for batch migration of users.
|
||||
|
||||
### Just-in-time migration
|
||||
|
||||
@ -71,7 +78,7 @@ For all other cases, we recommend that the **legacy system orchestrates the migr
|
||||
- Update your legacy system to create a user in ZITADEL on their next login, if not already flagged as migrated, by using our APIs (you can set the password and a verified email)
|
||||
- Redirect migrated users with a login hint in the [auth request](/docs/apis/openidoauth/authrequest.mdx) to ZITADEL to pre-select the user
|
||||
|
||||
In this case the migration can also be done as an import job or also allowing to create user session in both the legacy auth solution and ZITADEL in parallel with identity brokering:
|
||||
In this case the migration can also be done as an import job or also allowing to create user session in both the legacy auth solution and ZITADEL in parallel with identity brokering:
|
||||
|
||||
- Setup ZITADEL to use your legacy system as external identity provider (note: you can also use JWT-IDP, if you only have a token).
|
||||
- Configure your app to use ZITADEL, which will redirect users automatically to the external identity provider to login.
|
||||
|
@ -276,24 +276,10 @@ And with that, configuring ZITADEL for our application is complete. Now we can m
|
||||
|
||||
### 2. Prerequisites
|
||||
|
||||
To follow along with this tutorial, you will need to have both React and Visual Studio Code (VSCode) installed on your machine.
|
||||
|
||||
<b> React </b>
|
||||
To follow along with this tutorial, you will need to have both Node.js and Visual Studio Code (VSCode) installed on your machine.
|
||||
|
||||
To install React, you will need to have Node.js installed on your system. You can download and install Node.js from [here](https://nodejs.org/).
|
||||
|
||||
Once you have Node.js installed, you can use the following command to install React:
|
||||
|
||||
``` npm install -g react ```
|
||||
|
||||
This will install the latest version of React globally on your system. You can then verify the installation by running the following command:
|
||||
|
||||
``` react -v ```
|
||||
|
||||
This should output the version of React that you have installed.
|
||||
|
||||
<b> Visual Studio Code </b>
|
||||
|
||||
To install Visual Studio Code, go to their [website](https://code.visualstudio.com/) and download and install the version for your operating system.
|
||||
|
||||
|
||||
|
@ -2,7 +2,6 @@
|
||||
title: Cloud Service
|
||||
custom_edit_url: null
|
||||
---
|
||||
## Introduction
|
||||
|
||||
This annex of the [Framework Agreement](terms-of-service) describes the service levels offered by us for our Services.
|
||||
|
||||
@ -28,4 +27,13 @@ The following regions will be available when using our cloud service. This list
|
||||
- **Switzerland**: Exclusively on Swiss region
|
||||
- **GDPR safe countries**: Exclusively [Adequate Countries](https://ec.europa.eu/info/law/law-topic/data-protection/international-dimension-data-protection/adequacy-decisions_en) as recognized by the European Commission under the GDPR
|
||||
|
||||
Last revised: June 14, 2022
|
||||
## Backup
|
||||
|
||||
Our backup strategy executes daily full backups and differential backups on much higher frequency.
|
||||
In a disaster recovery scenario, our goal is to guarantee a recovery point objective (RPO) of 1h, and a higher but similar recovery time objective (RTO).
|
||||
Under normal operations, RPO and RTO goals are below 1 minute.
|
||||
|
||||
If you you have different requirements we provide you with a flexible approach to backup, restore, and transfer data (f.e. to a self-hosted setup) through our APIs.
|
||||
Please consult the [migration guides](../guides/migrate/introduction.md) for more information.
|
||||
|
||||
Last revised: June 21, 2023
|
@ -17,7 +17,7 @@ Last revised: June 14, 2022
|
||||
|
||||
**Downtime** means any period of time in which Core Services are not Available within the Region of the customer’s organization. Downtime excludes any time in which ZITADEL Cloud is not Available because of
|
||||
|
||||
- Announced maintenance work
|
||||
- [Announced maintenance work](/docs/support/software-release-cycles-support#maintenance)
|
||||
- Emergency maintenance
|
||||
- Force majeure events.
|
||||
|
||||
|
@ -132,7 +132,7 @@ Projections:
|
||||
RequeueEvery: 300s
|
||||
```
|
||||
|
||||
### Manage your Data
|
||||
### Manage your data
|
||||
|
||||
When designing your backup strategy,
|
||||
it is worth knowing that
|
||||
@ -151,7 +151,7 @@ please refer to the corresponding docs
|
||||
or [for PostgreSQL](https://www.postgresql.org/docs/current/admin.html).
|
||||
|
||||
|
||||
## Data Initialization
|
||||
## Data initialization
|
||||
|
||||
- You can configure instance defaults in the DefaultInstance section.
|
||||
If you plan to eventually create [multiple virtual instances](/concepts/structure/instance#multiple-virtual-instances), these defaults take effect.
|
||||
@ -188,3 +188,19 @@ DefaultInstance:
|
||||
|
||||
If you host ZITADEL as a service,
|
||||
you might want to [limit usage and/or execute tasks on certain usage units and levels](/self-hosting/manage/quotas).
|
||||
|
||||
## Minimum system requirements
|
||||
|
||||
### General resource usage
|
||||
|
||||
ZITADEL consumes around 512MB RAM and can run with less than 1 CPU core.
|
||||
The database consumes around 2 CPU under normal conditions and 6GB RAM with some caching to it.
|
||||
|
||||
:::info Password hashing
|
||||
Be aware of CPU spikes when hashing passwords. We recommend to have 4 CPU cores available for this purpose.
|
||||
:::
|
||||
|
||||
### Production HA cluster
|
||||
|
||||
It is recommended to build a minimal high-availability with 3 Nodes with 4 CPU and 16GB memory each.
|
||||
Excluding non-essential services, such as log collection, metrics etc, the resources could be reduced to around 4 CPU and 8GB memory each.
|
||||
|
@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Support States & Software Release Cycle
|
||||
title: Support states & software release cycle
|
||||
---
|
||||
|
||||
## Support States
|
||||
## Support states
|
||||
|
||||
It's important to note that support may differ depending on the feature, and not all features may be fully supported.
|
||||
It's important to note that support may differ depending on the feature, and not all features may be fully supported.
|
||||
We always strive to provide the best support possible for our customers and community,
|
||||
but we may not be able to provide immediate or comprehensive support for all features.
|
||||
Also the support may differ depending on your contracts. Read more about it on our [Legal page](/docs/legal)
|
||||
@ -21,7 +21,7 @@ In case you are eligible to [support services](/docs/legal/support-services) get
|
||||
Please report any security issues immediately to the indicated address in our [security.txt](https://zitadel.com/.well-known/security.txt)
|
||||
:::
|
||||
|
||||
### Enterprise Supported
|
||||
### Enterprise supported
|
||||
|
||||
Enterprise supported features are those where we provide support only to users eligible for enterprise [support services](/docs/legal/support-services).
|
||||
These features should be functional for eligible users, but may have some limitations for a broader use.
|
||||
@ -34,39 +34,94 @@ If you encounter issues with an enterprise supported feature and you are eligibl
|
||||
- LDAP Identity Provider
|
||||
- [Terraform Provider](https://github.com/zitadel/terraform-provider-zitadel)
|
||||
|
||||
### Community Supported
|
||||
### Community supported
|
||||
|
||||
Community supported features are those that have been developed by our community and may not have undergone extensive testing or support from our team.
|
||||
Community supported features are those that have been developed by our community and may not have undergone extensive testing or support from our team.
|
||||
If you encounter issues with a community supported feature, we encourage you to seek help from our community or other online resources, where other users can provide assistance:
|
||||
|
||||
- Join our [Discord Chat](https://zitadel.com/chat)
|
||||
- Search [Github Issues](https://github.com/search?q=org%3Azitadel+&type=issues) and report a new issue
|
||||
- Search [Github Discussions](https://github.com/search?q=org%3Azitadel+&type=discussions) and open a new discussion as question or idea
|
||||
|
||||
## Software Release Cycle
|
||||
## Software release cycle
|
||||
|
||||
It's important to note that both Alpha and Beta software can have breaking changes, meaning they are not backward-compatible with previous versions of the software.
|
||||
Therefore, it's recommended to use caution when using Alpha and Beta software, and to always back up important data before installing or testing new software versions.
|
||||
|
||||
Only features in General Availability will be covered by support services.
|
||||
|
||||
We encourage our community to test Alpha and Beta software and provide feedback via our [Discord Chat](https://zitadel.com/chat).
|
||||
We encourage our community to test Alpha and Beta software and provide feedback via our [Discord Chat](https://zitadel.com/chat).
|
||||
|
||||
### Alpha
|
||||
|
||||
The Alpha state is our initial testing phase.
|
||||
The Alpha state is our initial testing phase.
|
||||
It is available to everyone, but it is not yet complete and may contain bugs and incomplete features.
|
||||
We recommend that users exercise caution when using Alpha software and avoid using it for critical tasks, as support is limited during this phase.
|
||||
|
||||
### Beta
|
||||
|
||||
The Beta state comes after the Alpha phase and is a more stable version of the software.
|
||||
The Beta state comes after the Alpha phase and is a more stable version of the software.
|
||||
It is feature-complete, but may still contain bugs that need to be fixed before general availability.
|
||||
While it is available to everyone, we recommend that users exercise caution when using Beta software and avoid using it for critical tasks.
|
||||
During this phase, support is limited as we focus on testing and bug fixing.
|
||||
While it is available to everyone, we recommend that users exercise caution when using Beta software and avoid using it for critical tasks.
|
||||
During this phase, support is limited as we focus on testing and bug fixing.
|
||||
|
||||
### General Available
|
||||
### General available
|
||||
|
||||
Generally available features are available to everyone and have the appropriate test coverage to be used for critical tasks.
|
||||
The software will be backwards-compatible with previous versions, for exceptions we will publish a [technical advisory](https://zitadel.com/docs/support/technical_advisory).
|
||||
Features in General Availability are not marked explicitly.
|
||||
|
||||
## Release types
|
||||
|
||||
All release channels receive regular updates and bug fixes.
|
||||
However, the timing and frequency of updates may differ between the channels.
|
||||
The choice between the "release candidate", "latest" and "stable" release channels depends on the specific requirements, preferences, and risk tolerance of the users.
|
||||
|
||||
[List of all releases](https://github.com/zitadel/zitadel/releases)
|
||||
|
||||
### Release candidate
|
||||
|
||||
A release candidate refers to a pre-release version that is distributed to a limited group of users or customers for testing and evaluation purposes before a wider release.
|
||||
It allows a selected group, such as our open source community or early adopters, to provide valuable feedback, identify potential issues, and help refine the software.
|
||||
Please note that since it is not the final version, the release candidate may still contain some bugs or issues that are addressed before the official release.
|
||||
|
||||
Release candidates are accessible for our open source community, but will not be deployed to the ZITADEL Cloud Platform.
|
||||
|
||||
### Latest
|
||||
|
||||
The "latest" release channel is designed for users who prefer to access the most recent updates, features, and enhancements as soon as they become available.
|
||||
It provides early access to new functionalities and improvements but may involve a higher degree of risk as it is the most actively developed version.
|
||||
Users opting for the latest release channel should be aware that occasional bugs or issues may arise due to the ongoing development process.
|
||||
|
||||
### Stable
|
||||
|
||||
The "stable" release channel is intended for users seeking a more reliable and production-ready version of the software.
|
||||
It offers a well-tested and validated release with fewer known issues and a higher level of stability.
|
||||
The stable release channel undergoes rigorous quality assurance and testing processes to ensure that it meets the highest standards of reliability and performance.
|
||||
It is recommended for users who prioritize stability over immediate access to the latest features.
|
||||
|
||||
Current Stable Version:
|
||||
|
||||
```yaml reference
|
||||
https://github.com/zitadel/zitadel/blob/main/release-channels.yaml
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
ZITADEL Cloud follows a regular deployment cycle to ensure our product remains up-to-date, secure, and provides new features.
|
||||
Our standard deployment cycle occurs every two weeks, during which we implement updates, bug fixes, and enhancements to improve the functionality and performance of our product.
|
||||
In certain circumstances, we may require additional deployments beyond the regular two-week cycle.
|
||||
This can occur for example when we have substantial updates or feature releases that require additional time for thorough testing and implementation or security fixes.
|
||||
During deployments, we strive to minimize any disruptions and do not expect any downtime.
|
||||
|
||||
### Release deployment with risk of downtime
|
||||
|
||||
In rare situations where deploying releases that may carry a risk of increased latency or short downtime, we have a well-defined procedure in place to ensure transparent communication.
|
||||
Prior to such deployments, we publish information on our status page, which can be accessed by visiting [https://status.zitadel.com/](https://status.zitadel.com/).
|
||||
We also recommend that you subscribe to those updates on the [status page](https://status.zitadel.com/).
|
||||
|
||||
We make it a priority to inform you of any potential impact well in advance.
|
||||
In adherence to our commitment to transparency, we provide a minimum notice period of five working days before deploying a release that poses a risk of downtime.
|
||||
This gives you time to plan accordingly, make any necessary adjustments, or reach out to our support team for assistance.
|
||||
|
||||
Our team works diligently to minimize the risk of downtime during these releases. We thoroughly test and verify each update before deployment to ensure the highest level of stability and reliability.
|
||||
|
@ -167,6 +167,26 @@ module.exports = {
|
||||
"guides/integrate/pat",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Build your own Login-UI",
|
||||
link: {
|
||||
type: "generated-index",
|
||||
title: "Build your own Login-UI",
|
||||
slug: "/guides/integrate/login-ui",
|
||||
description:
|
||||
"In the following guides you will learn how to create your own login ui with our APIs. The different scenarios like username/password, external identity provider, etc will be shown.",
|
||||
|
||||
},
|
||||
collapsed: true,
|
||||
items: [
|
||||
"guides/integrate/login-ui/username-password",
|
||||
"guides/integrate/login-ui/external-login",
|
||||
"guides/integrate/login-ui/select-account",
|
||||
"guides/integrate/login-ui/logout",
|
||||
"guides/integrate/login-ui/oidc-standard"
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Configure identity providers",
|
||||
|
BIN
docs/static/img/guides/login-ui/external-login-flow.png
vendored
Normal file
BIN
docs/static/img/guides/login-ui/external-login-flow.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 280 KiB |
BIN
docs/static/img/guides/login-ui/oidc-flow.png
vendored
Normal file
BIN
docs/static/img/guides/login-ui/oidc-flow.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 254 KiB |
BIN
docs/static/img/guides/login-ui/username-password-flow.png
vendored
Normal file
BIN
docs/static/img/guides/login-ui/username-password-flow.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 270 KiB |
6
go.mod
6
go.mod
@ -55,11 +55,11 @@ require (
|
||||
github.com/sony/sonyflake v1.1.0
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/spf13/viper v1.15.0
|
||||
github.com/stretchr/testify v1.8.3
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203
|
||||
github.com/ttacon/libphonenumber v1.2.1
|
||||
github.com/zitadel/logging v0.3.4
|
||||
github.com/zitadel/oidc/v2 v2.6.1
|
||||
github.com/zitadel/oidc/v2 v2.6.3
|
||||
github.com/zitadel/saml v0.0.11
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.40.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.40.0
|
||||
@ -183,7 +183,7 @@ require (
|
||||
github.com/prometheus/procfs v0.9.0 // indirect
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.3.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.2
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/afero v1.9.5 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
|
12
go.sum
12
go.sum
@ -812,8 +812,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
|
||||
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.0 h1:UVQPSSmc3qtTi+zPPkCXvZX9VvW/xT/NsRvKfwY81a8=
|
||||
github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
@ -864,8 +864,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
|
||||
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
|
||||
github.com/superseriousbusiness/exifremove v0.0.0-20210330092427-6acd27eac203 h1:1SWXcTphBQjYGWRRxLFIAR1LVtQEj4eR7xPtyeOVM/c=
|
||||
@ -900,8 +900,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM=
|
||||
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
|
||||
github.com/zitadel/oidc/v2 v2.6.1 h1:NIW4/KPi9Q2Pae6MKEhrSl+DSC5W4574GlboHildroc=
|
||||
github.com/zitadel/oidc/v2 v2.6.1/go.mod h1:vsFNrYCj2x0it0pYmIRVZ12HJ1VGaMVyGl7HLqw5p+Y=
|
||||
github.com/zitadel/oidc/v2 v2.6.3 h1:YY87cAcdI+3voZqcRU2RGz3Pxky/2KsjDmYDVb6EgWw=
|
||||
github.com/zitadel/oidc/v2 v2.6.3/go.mod h1:2LrbdKYLSgKxXBfct56ev4e186J7TXotlZxb6tExOO4=
|
||||
github.com/zitadel/saml v0.0.11 h1:kObucnBrcu1PHCO7RGT0iVeuJL/5I50gUgr40S41nMs=
|
||||
github.com/zitadel/saml v0.0.11/go.mod h1:YGWAvPZRv4DbEZ78Ht/2P0AWzGn+6WGhFf90PMXl0Po=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
|
@ -3,9 +3,19 @@ package grpc
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
var CustomMappers = map[protoreflect.FullName]func(testing.TB, protoreflect.ProtoMessage) any{
|
||||
"google.protobuf.Struct": func(t testing.TB, msg protoreflect.ProtoMessage) any {
|
||||
e, ok := msg.(*structpb.Struct)
|
||||
require.True(t, ok)
|
||||
return e.AsMap()
|
||||
},
|
||||
}
|
||||
|
||||
// AllFieldsSet recusively checks if all values in a message
|
||||
// have a non-zero value.
|
||||
func AllFieldsSet(t testing.TB, msg protoreflect.Message, ignoreTypes ...protoreflect.FullName) {
|
||||
@ -36,3 +46,24 @@ func AllFieldsSet(t testing.TB, msg protoreflect.Message, ignoreTypes ...protore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func AllFieldsEqual(t testing.TB, expected, actual protoreflect.Message, customMappers map[protoreflect.FullName]func(testing.TB, protoreflect.ProtoMessage) any) {
|
||||
md := expected.Descriptor()
|
||||
name := md.FullName()
|
||||
if mapper := customMappers[name]; mapper != nil {
|
||||
require.Equal(t, mapper(t, expected.Interface()), mapper(t, actual.Interface()))
|
||||
return
|
||||
}
|
||||
|
||||
fields := md.Fields()
|
||||
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
fd := fields.Get(i)
|
||||
|
||||
if fd.Kind() == protoreflect.MessageKind {
|
||||
AllFieldsEqual(t, expected.Get(fd).Message(), actual.Get(fd).Message(), customMappers)
|
||||
} else {
|
||||
require.Equal(t, expected.Get(fd).Interface(), actual.Get(fd).Interface())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe
|
||||
}
|
||||
challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
|
||||
|
||||
set, err := s.command.CreateSession(ctx, cmds, metadata)
|
||||
set, err := s.command.CreateSession(ctx, cmds, req.GetDomain(), metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -107,6 +107,7 @@ func sessionToPb(s *query.Session) *session.Session {
|
||||
Sequence: s.Sequence,
|
||||
Factors: factorsToPb(s),
|
||||
Metadata: s.Metadata,
|
||||
Domain: s.Domain,
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,6 +120,7 @@ func factorsToPb(s *query.Session) *session.Factors {
|
||||
User: user,
|
||||
Password: passwordFactorToPb(s.PasswordFactor),
|
||||
Passkey: passkeyFactorToPb(s.PasskeyFactor),
|
||||
Intent: intentFactorToPb(s.IntentFactor),
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,6 +133,15 @@ func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFac
|
||||
}
|
||||
}
|
||||
|
||||
func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor {
|
||||
if factor.IntentCheckedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &session.IntentFactor{
|
||||
VerifiedAt: timestamppb.New(factor.IntentCheckedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func passkeyFactorToPb(factor query.SessionPasskeyFactor) *session.PasskeyFactor {
|
||||
if factor.PasskeyCheckedAt.IsZero() {
|
||||
return nil
|
||||
@ -229,6 +240,9 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
|
||||
if password := checks.GetPassword(); password != nil {
|
||||
sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword()))
|
||||
}
|
||||
if intent := checks.GetIntent(); intent != nil {
|
||||
sessionChecks = append(sessionChecks, command.CheckIntent(intent.GetIntentId(), intent.GetToken()))
|
||||
}
|
||||
if passkey := checks.GetPasskey(); passkey != nil {
|
||||
sessionChecks = append(sessionChecks, s.command.CheckPasskey(passkey.GetCredentialAssertionData()))
|
||||
}
|
||||
|
@ -10,24 +10,26 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var (
|
||||
CTX context.Context
|
||||
Tester *integration.Tester
|
||||
Client session.SessionServiceClient
|
||||
User *user.AddHumanUserResponse
|
||||
CTX context.Context
|
||||
Tester *integration.Tester
|
||||
Client session.SessionServiceClient
|
||||
User *user.AddHumanUserResponse
|
||||
GenericOAuthIDPID string
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(func() int {
|
||||
ctx, errCtx, cancel := integration.Contexts(time.Hour)
|
||||
ctx, errCtx, cancel := integration.Contexts(5 * time.Minute)
|
||||
defer cancel()
|
||||
|
||||
Tester = integration.NewTester(ctx)
|
||||
@ -55,7 +57,7 @@ retry:
|
||||
s = resp.GetSession()
|
||||
break retry
|
||||
}
|
||||
if status.Convert(err).Code() == codes.NotFound {
|
||||
if code := status.Convert(err).Code(); code == codes.NotFound || code == codes.PermissionDenied {
|
||||
select {
|
||||
case <-CTX.Done():
|
||||
t.Fatal(CTX.Err(), err)
|
||||
@ -82,6 +84,7 @@ const (
|
||||
wantUserFactor wantFactor = iota
|
||||
wantPasswordFactor
|
||||
wantPasskeyFactor
|
||||
wantIntentFactor
|
||||
)
|
||||
|
||||
func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, want []wantFactor) {
|
||||
@ -100,6 +103,10 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration,
|
||||
pf := factors.GetPasskey()
|
||||
assert.NotNil(t, pf)
|
||||
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
case wantIntentFactor:
|
||||
pf := factors.GetIntent()
|
||||
assert.NotNil(t, pf)
|
||||
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -134,6 +141,7 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Metadata: map[string][]byte{"foo": []byte("bar")},
|
||||
Domain: "domain",
|
||||
},
|
||||
want: &session.CreateSessionResponse{
|
||||
Details: &object.Details{
|
||||
@ -162,6 +170,22 @@ func TestServer_CreateSession(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "passkey without domain (not registered) error",
|
||||
req: &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{
|
||||
UserId: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Challenges: []session.ChallengeKind{
|
||||
session.ChallengeKind_CHALLENGE_KIND_PASSKEY,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@ -191,6 +215,7 @@ func TestServer_CreateSession_passkey(t *testing.T) {
|
||||
Challenges: []session.ChallengeKind{
|
||||
session.ChallengeKind_CHALLENGE_KIND_PASSKEY,
|
||||
},
|
||||
Domain: Tester.Config.ExternalDomain,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
|
||||
@ -212,11 +237,113 @@ func TestServer_CreateSession_passkey(t *testing.T) {
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantPasskeyFactor)
|
||||
}
|
||||
|
||||
func TestServer_CreateSession_successfulIntent(t *testing.T) {
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
|
||||
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{
|
||||
UserId: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
|
||||
|
||||
intentID, token, _, _ := Tester.CreateSuccessfulIntent(t, idpID, User.GetUserId(), "id")
|
||||
updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: createResp.GetSessionToken(),
|
||||
Checks: &session.Checks{
|
||||
Intent: &session.CheckIntent{
|
||||
IntentId: intentID,
|
||||
Token: token,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantIntentFactor)
|
||||
}
|
||||
|
||||
func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) {
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
|
||||
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{
|
||||
UserId: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
|
||||
|
||||
idpUserID := "id"
|
||||
intentID, token, _, _ := Tester.CreateSuccessfulIntent(t, idpID, "", idpUserID)
|
||||
updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: createResp.GetSessionToken(),
|
||||
Checks: &session.Checks{
|
||||
Intent: &session.CheckIntent{
|
||||
IntentId: intentID,
|
||||
Token: token,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
Tester.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId())
|
||||
updateResp, err = Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: createResp.GetSessionToken(),
|
||||
Checks: &session.Checks{
|
||||
Intent: &session.CheckIntent{
|
||||
IntentId: intentID,
|
||||
Token: token,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantIntentFactor)
|
||||
}
|
||||
|
||||
func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) {
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
|
||||
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{
|
||||
UserId: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
|
||||
|
||||
intentID := Tester.CreateIntent(t, idpID)
|
||||
_, err = Client.SetSession(CTX, &session.SetSessionRequest{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: createResp.GetSessionToken(),
|
||||
Checks: &session.Checks{
|
||||
Intent: &session.CheckIntent{
|
||||
IntentId: intentID,
|
||||
Token: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestServer_SetSession_flow(t *testing.T) {
|
||||
var wantFactors []wantFactor
|
||||
|
||||
// create new, empty session
|
||||
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
|
||||
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{Domain: Tester.Config.ExternalDomain})
|
||||
require.NoError(t, err)
|
||||
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...)
|
||||
sessionToken := createResp.GetSessionToken()
|
||||
|
@ -3,13 +3,13 @@ package user
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
@ -20,11 +20,11 @@ func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyR
|
||||
)
|
||||
if code := req.GetCode(); code != nil {
|
||||
return passkeyRegistrationDetailsToPb(
|
||||
s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), resourceOwner, authenticator, code.Id, code.Code, s.userCodeAlg),
|
||||
s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), resourceOwner, authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg),
|
||||
)
|
||||
}
|
||||
return passkeyRegistrationDetailsToPb(
|
||||
s.command.RegisterUserPasskey(ctx, req.GetUserId(), resourceOwner, authenticator),
|
||||
s.command.RegisterUserPasskey(ctx, req.GetUserId(), resourceOwner, req.GetDomain(), authenticator),
|
||||
)
|
||||
}
|
||||
|
||||
@ -41,24 +41,32 @@ func passkeyAuthenticatorToDomain(pa user.PasskeyAuthenticator) domain.Authentic
|
||||
}
|
||||
}
|
||||
|
||||
func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
|
||||
func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*object_pb.Details, *structpb.Struct, error) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
options := new(structpb.Struct)
|
||||
if err := options.UnmarshalJSON(details.PublicKeyCredentialCreationOptions); err != nil {
|
||||
return nil, nil, caos_errs.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal")
|
||||
}
|
||||
return object.DomainToDetailsPb(details.ObjectDetails), options, nil
|
||||
}
|
||||
|
||||
func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
|
||||
objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := new(structpb.Struct)
|
||||
if err := protojson.Unmarshal(details.PublicKeyCredentialCreationOptions, options); err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal")
|
||||
}
|
||||
return &user.RegisterPasskeyResponse{
|
||||
Details: object.DomainToDetailsPb(details.ObjectDetails),
|
||||
PasskeyId: details.PasskeyID,
|
||||
Details: objectDetails,
|
||||
PasskeyId: details.ID,
|
||||
PublicKeyCredentialCreationOptions: options,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
|
||||
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
|
||||
pkc, err := protojson.Marshal(req.GetPublicKeyCredential())
|
||||
pkc, err := req.GetPublicKeyCredential().MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal")
|
||||
}
|
||||
|
@ -94,7 +94,8 @@ func TestServer_RegisterPasskey(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
/* TODO after we are able to obtain a Bearer token for a human user
|
||||
/* TODO: after we are able to obtain a Bearer token for a human user
|
||||
https://github.com/zitadel/zitadel/issues/6022
|
||||
{
|
||||
name: "human user",
|
||||
args: args{
|
||||
|
@ -50,7 +50,7 @@ func Test_passkeyAuthenticatorToDomain(t *testing.T) {
|
||||
|
||||
func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
|
||||
type args struct {
|
||||
details *domain.PasskeyRegistrationDetails
|
||||
details *domain.WebAuthNRegistrationDetails
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
@ -70,13 +70,13 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
|
||||
{
|
||||
name: "unmarshall error",
|
||||
args: args{
|
||||
details: &domain.PasskeyRegistrationDetails{
|
||||
details: &domain.WebAuthNRegistrationDetails{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
PasskeyID: "123",
|
||||
ID: "123",
|
||||
PublicKeyCredentialCreationOptions: []byte(`\\`),
|
||||
},
|
||||
err: nil,
|
||||
@ -86,13 +86,13 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
|
||||
{
|
||||
name: "ok",
|
||||
args: args{
|
||||
details: &domain.PasskeyRegistrationDetails{
|
||||
details: &domain.WebAuthNRegistrationDetails{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
PasskeyID: "123",
|
||||
ID: "123",
|
||||
PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
|
||||
},
|
||||
err: nil,
|
||||
|
71
internal/api/grpc/user/v2/password.go
Normal file
71
internal/api/grpc/user/v2/password.go
Normal file
@ -0,0 +1,71 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) {
|
||||
var details *domain.ObjectDetails
|
||||
var code *string
|
||||
|
||||
switch m := req.GetMedium().(type) {
|
||||
case *user.PasswordResetRequest_SendLink:
|
||||
details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType()))
|
||||
case *user.PasswordResetRequest_ReturnCode:
|
||||
details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId())
|
||||
case nil:
|
||||
details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId())
|
||||
default:
|
||||
err = caos_errs.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user.PasswordResetResponse{
|
||||
Details: object.DomainToDetailsPb(details),
|
||||
VerificationCode: code,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType {
|
||||
switch notificationType {
|
||||
case user.NotificationType_NOTIFICATION_TYPE_Email:
|
||||
return domain.NotificationTypeEmail
|
||||
case user.NotificationType_NOTIFICATION_TYPE_SMS:
|
||||
return domain.NotificationTypeSms
|
||||
case user.NotificationType_NOTIFICATION_TYPE_Unspecified:
|
||||
return domain.NotificationTypeEmail
|
||||
default:
|
||||
return domain.NotificationTypeEmail
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) {
|
||||
var resourceOwner = authz.GetCtxData(ctx).ResourceOwner
|
||||
var details *domain.ObjectDetails
|
||||
|
||||
switch v := req.GetVerification().(type) {
|
||||
case *user.SetPasswordRequest_CurrentPassword:
|
||||
details, err = s.command.ChangePassword(ctx, resourceOwner, req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "")
|
||||
case *user.SetPasswordRequest_VerificationCode:
|
||||
details, err = s.command.SetPasswordWithVerifyCode(ctx, resourceOwner, req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "")
|
||||
case nil:
|
||||
details, err = s.command.SetPassword(ctx, resourceOwner, req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired())
|
||||
default:
|
||||
err = caos_errs.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user.SetPasswordResponse{
|
||||
Details: object.DomainToDetailsPb(details),
|
||||
}, nil
|
||||
}
|
232
internal/api/grpc/user/v2/password_integration_test.go
Normal file
232
internal/api/grpc/user/v2/password_integration_test.go
Normal file
@ -0,0 +1,232 @@
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func TestServer_RequestPasswordReset(t *testing.T) {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *user.PasswordResetRequest
|
||||
want *user.PasswordResetResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default medium",
|
||||
req: &user.PasswordResetRequest{
|
||||
UserId: userID,
|
||||
},
|
||||
want: &user.PasswordResetResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 1,
|
||||
ChangeDate: timestamppb.Now(),
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom url template",
|
||||
req: &user.PasswordResetRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.PasswordResetRequest_SendLink{
|
||||
SendLink: &user.SendPasswordResetLink{
|
||||
NotificationType: user.NotificationType_NOTIFICATION_TYPE_Email,
|
||||
UrlTemplate: gu.Ptr("https://example.com/password/change?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &user.PasswordResetResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 1,
|
||||
ChangeDate: timestamppb.Now(),
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template error",
|
||||
req: &user.PasswordResetRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.PasswordResetRequest_SendLink{
|
||||
SendLink: &user.SendPasswordResetLink{
|
||||
UrlTemplate: gu.Ptr("{{"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "return code",
|
||||
req: &user.PasswordResetRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.PasswordResetRequest_ReturnCode{
|
||||
ReturnCode: &user.ReturnPasswordResetCode{},
|
||||
},
|
||||
},
|
||||
want: &user.PasswordResetResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 1,
|
||||
ChangeDate: timestamppb.Now(),
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
VerificationCode: gu.Ptr("xxx"),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.PasswordReset(CTX, tt.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
if tt.want.GetVerificationCode() != "" {
|
||||
assert.NotEmpty(t, got.GetVerificationCode())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_SetPassword(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.SetPasswordRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(request *user.SetPasswordRequest) error
|
||||
args args
|
||||
want *user.SetPasswordResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
prepare: func(request *user.SetPasswordRequest) error {
|
||||
return nil
|
||||
},
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.SetPasswordRequest{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "set successful",
|
||||
prepare: func(request *user.SetPasswordRequest) error {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
request.UserId = userID
|
||||
return nil
|
||||
},
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.SetPasswordRequest{
|
||||
NewPassword: &user.Password{
|
||||
Password: "Secr3tP4ssw0rd!",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &user.SetPasswordResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "change successful",
|
||||
prepare: func(request *user.SetPasswordRequest) error {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
request.UserId = userID
|
||||
_, err := Client.SetPassword(CTX, &user.SetPasswordRequest{
|
||||
UserId: userID,
|
||||
NewPassword: &user.Password{
|
||||
Password: "InitialPassw0rd!",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.SetPasswordRequest{
|
||||
NewPassword: &user.Password{
|
||||
Password: "Secr3tP4ssw0rd!",
|
||||
},
|
||||
Verification: &user.SetPasswordRequest_CurrentPassword{
|
||||
CurrentPassword: "InitialPassw0rd!",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &user.SetPasswordResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set with code successful",
|
||||
prepare: func(request *user.SetPasswordRequest) error {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
request.UserId = userID
|
||||
resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.PasswordResetRequest_ReturnCode{
|
||||
ReturnCode: &user.ReturnPasswordResetCode{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Verification = &user.SetPasswordRequest_VerificationCode{
|
||||
VerificationCode: resp.GetVerificationCode(),
|
||||
}
|
||||
return nil
|
||||
},
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.SetPasswordRequest{
|
||||
NewPassword: &user.Password{
|
||||
Password: "Secr3tP4ssw0rd!",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &user.SetPasswordResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.prepare(tt.args.req)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := Client.SetPassword(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
39
internal/api/grpc/user/v2/password_test.go
Normal file
39
internal/api/grpc/user/v2/password_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func Test_notificationTypeToDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notificationType user.NotificationType
|
||||
want domain.NotificationType
|
||||
}{
|
||||
{
|
||||
"unspecified",
|
||||
user.NotificationType_NOTIFICATION_TYPE_Unspecified,
|
||||
domain.NotificationTypeEmail,
|
||||
},
|
||||
{
|
||||
"email",
|
||||
user.NotificationType_NOTIFICATION_TYPE_Email,
|
||||
domain.NotificationTypeEmail,
|
||||
},
|
||||
{
|
||||
"sms",
|
||||
user.NotificationType_NOTIFICATION_TYPE_SMS,
|
||||
domain.NotificationTypeSms,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, notificationTypeToDomain(tt.notificationType), "notificationTypeToDomain(%v)", tt.notificationType)
|
||||
})
|
||||
}
|
||||
}
|
38
internal/api/grpc/user/v2/totp.go
Normal file
38
internal/api/grpc/user/v2/totp.go
Normal file
@ -0,0 +1,38 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) {
|
||||
return totpDetailsToPb(
|
||||
s.command.AddUserTOTP(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.RegisterTOTPResponse{
|
||||
Details: object.DomainToDetailsPb(totp.ObjectDetails),
|
||||
Uri: totp.URI,
|
||||
Secret: totp.Secret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) {
|
||||
objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), authz.GetCtxData(ctx).ResourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.VerifyTOTPRegistrationResponse{
|
||||
Details: object.DomainToDetailsPb(objectDetails),
|
||||
}, nil
|
||||
}
|
155
internal/api/grpc/user/v2/totp_integration_test.go
Normal file
155
internal/api/grpc/user/v2/totp_integration_test.go
Normal file
@ -0,0 +1,155 @@
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func TestServer_RegisterTOTP(t *testing.T) {
|
||||
// userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.RegisterTOTPRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.RegisterTOTPResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterTOTPRequest{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "user mismatch",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterTOTPRequest{
|
||||
UserId: "wrong",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
/* TODO: after we are able to obtain a Bearer token for a human user
|
||||
https://github.com/zitadel/zitadel/issues/6022
|
||||
{
|
||||
name: "human user",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterTOTPRequest{
|
||||
UserId: userID,
|
||||
},
|
||||
},
|
||||
want: &user.RegisterTOTPResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.RegisterTOTP(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
assert.NotEmpty(t, got.GetUri())
|
||||
assert.NotEmpty(t, got.GetSecret())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VerifyTOTPRegistration(t *testing.T) {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
|
||||
/* TODO: after we are able to obtain a Bearer token for a human user
|
||||
reg, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
code, err := totp.GenerateCode(reg.Secret, time.Now())
|
||||
require.NoError(t, err)
|
||||
*/
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.VerifyTOTPRegistrationRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.VerifyTOTPRegistrationResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "user mismatch",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyTOTPRegistrationRequest{
|
||||
UserId: "wrong",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong code",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyTOTPRegistrationRequest{
|
||||
UserId: userID,
|
||||
Code: "123",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
/* TODO: after we are able to obtain a Bearer token for a human user
|
||||
https://github.com/zitadel/zitadel/issues/6022
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyTOTPRegistrationRequest{
|
||||
UserId: userID,
|
||||
Code: code,
|
||||
},
|
||||
},
|
||||
want: &user.VerifyTOTPRegistrationResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ResourceOwner,
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.VerifyTOTPRegistration(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
71
internal/api/grpc/user/v2/totp_test.go
Normal file
71
internal/api/grpc/user/v2/totp_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func Test_totpDetailsToPb(t *testing.T) {
|
||||
type args struct {
|
||||
otp *domain.TOTP
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.RegisterTOTPResponse
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "error",
|
||||
args: args{
|
||||
err: io.ErrClosedPipe,
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
otp: &domain.TOTP{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 123,
|
||||
EventDate: time.Unix(456, 789),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
Secret: "secret",
|
||||
URI: "URI",
|
||||
},
|
||||
},
|
||||
want: &user.RegisterTOTPResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 123,
|
||||
ChangeDate: ×tamppb.Timestamp{
|
||||
Seconds: 456,
|
||||
Nanos: 789,
|
||||
},
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
Secret: "secret",
|
||||
Uri: "URI",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := totpDetailsToPb(tt.args.otp, tt.args.err)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if !proto.Equal(tt.want, got) {
|
||||
t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
44
internal/api/grpc/user/v2/u2f.go
Normal file
44
internal/api/grpc/user/v2/u2f.go
Normal file
@ -0,0 +1,44 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) {
|
||||
return u2fRegistrationDetailsToPb(
|
||||
s.command.RegisterUserU2F(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner, req.GetDomain()),
|
||||
)
|
||||
}
|
||||
|
||||
func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) {
|
||||
objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.RegisterU2FResponse{
|
||||
Details: objectDetails,
|
||||
U2FId: details.ID,
|
||||
PublicKeyCredentialCreationOptions: options,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) {
|
||||
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
|
||||
pkc, err := req.GetPublicKeyCredential().MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal")
|
||||
}
|
||||
objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), resourceOwner, req.GetTokenName(), "", pkc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.VerifyU2FRegistrationResponse{
|
||||
Details: object.DomainToDetailsPb(objectDetails),
|
||||
}, nil
|
||||
}
|
167
internal/api/grpc/user/v2/u2f_integration_test.go
Normal file
167
internal/api/grpc/user/v2/u2f_integration_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func TestServer_RegisterU2F(t *testing.T) {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.RegisterU2FRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.RegisterU2FResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterU2FRequest{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "user mismatch",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterU2FRequest{
|
||||
UserId: userID,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
/* TODO: after we are able to obtain a Bearer token for a human user
|
||||
https://github.com/zitadel/zitadel/issues/6022
|
||||
{
|
||||
name: "human user",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterU2FRequest{
|
||||
UserId: userID,
|
||||
},
|
||||
},
|
||||
want: &user.RegisterU2FResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.RegisterU2F(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
if tt.want != nil {
|
||||
assert.NotEmpty(t, got.GetU2FId())
|
||||
assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
|
||||
_, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VerifyU2FRegistration(t *testing.T) {
|
||||
userID := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
/* TODO after we are able to obtain a Bearer token for a human user
|
||||
pkr, err := Client.RegisterU2F(CTX, &user.RegisterU2FRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
|
||||
|
||||
attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
|
||||
require.NoError(t, err)
|
||||
*/
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.VerifyU2FRegistrationRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.VerifyU2FRegistrationResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyU2FRegistrationRequest{
|
||||
U2FId: "123",
|
||||
TokenName: "nice name",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
/* TODO after we are able to obtain a Bearer token for a human user
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyU2FRegistrationRequest{
|
||||
UserId: userID,
|
||||
U2FId: pkr.GetU2FId(),
|
||||
PublicKeyCredential: attestationResponse,
|
||||
TokenName: "nice name",
|
||||
},
|
||||
},
|
||||
want: &user.VerifyU2FRegistrationResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
{
|
||||
name: "wrong credential",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyU2FRegistrationRequest{
|
||||
UserId: userID,
|
||||
U2FId: "123",
|
||||
PublicKeyCredential: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
|
||||
},
|
||||
TokenName: "nice name",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.VerifyU2FRegistration(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
97
internal/api/grpc/user/v2/u2f_test.go
Normal file
97
internal/api/grpc/user/v2/u2f_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/grpc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func Test_u2fRegistrationDetailsToPb(t *testing.T) {
|
||||
type args struct {
|
||||
details *domain.WebAuthNRegistrationDetails
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.RegisterU2FResponse
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "an error",
|
||||
args: args{
|
||||
details: nil,
|
||||
err: io.ErrClosedPipe,
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "unmarshall error",
|
||||
args: args{
|
||||
details: &domain.WebAuthNRegistrationDetails{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
ID: "123",
|
||||
PublicKeyCredentialCreationOptions: []byte(`\\`),
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
wantErr: caos_errs.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"),
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
args: args{
|
||||
details: &domain.WebAuthNRegistrationDetails{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
ID: "123",
|
||||
PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
want: &user.RegisterU2FResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 22,
|
||||
ChangeDate: ×tamppb.Timestamp{
|
||||
Seconds: 3000,
|
||||
Nanos: 22,
|
||||
},
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
U2FId: "123",
|
||||
PublicKeyCredentialCreationOptions: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if !proto.Equal(tt.want, got) {
|
||||
t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got)
|
||||
}
|
||||
if tt.want != nil {
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -2,10 +2,10 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
@ -64,8 +64,8 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman,
|
||||
for i, link := range req.GetIdpLinks() {
|
||||
links[i] = &command.AddLink{
|
||||
IDPID: link.GetIdpId(),
|
||||
IDPExternalID: link.GetIdpExternalId(),
|
||||
DisplayName: link.GetDisplayName(),
|
||||
IDPExternalID: link.GetUserId(),
|
||||
DisplayName: link.GetUserName(),
|
||||
}
|
||||
}
|
||||
return &command.AddHuman{
|
||||
@ -124,8 +124,8 @@ func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_
|
||||
orgID := authz.GetCtxData(ctx).OrgID
|
||||
details, err := s.command.AddUserIDPLink(ctx, req.UserId, orgID, &domain.UserIDPLink{
|
||||
IDPConfigID: req.GetIdpLink().GetIdpId(),
|
||||
ExternalUserID: req.GetIdpLink().GetIdpExternalId(),
|
||||
DisplayName: req.GetIdpLink().GetDisplayName(),
|
||||
ExternalUserID: req.GetIdpLink().GetUserId(),
|
||||
DisplayName: req.GetIdpLink().GetUserName(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -176,6 +176,12 @@ func intentToIDPInformationPb(intent *command.IDPIntentWriteModel, alg crypto.En
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rawInformation := new(structpb.Struct)
|
||||
err = rawInformation.UnmarshalJSON(intent.IDPUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user.RetrieveIdentityProviderInformationResponse{
|
||||
Details: &object_pb.Details{
|
||||
Sequence: intent.ProcessedSequence,
|
||||
@ -189,25 +195,52 @@ func intentToIDPInformationPb(intent *command.IDPIntentWriteModel, alg crypto.En
|
||||
IdToken: idToken,
|
||||
},
|
||||
},
|
||||
IdpInformation: intent.IDPUser,
|
||||
IdpId: intent.IDPID,
|
||||
UserId: intent.IDPUserID,
|
||||
UserName: intent.IDPUserName,
|
||||
RawInformation: rawInformation,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) checkIntentToken(token string, intentID string) error {
|
||||
if token == "" {
|
||||
return errors.ThrowPermissionDenied(nil, "IDP-Sfefs", "Errors.Intent.InvalidToken")
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return errors.ThrowPermissionDenied(err, "IDP-Swg31", "Errors.Intent.InvalidToken")
|
||||
}
|
||||
decryptedToken, err := s.idpAlg.Decrypt(data, s.idpAlg.EncryptionKeyID())
|
||||
if err != nil {
|
||||
return errors.ThrowPermissionDenied(err, "IDP-Sf4gt", "Errors.Intent.InvalidToken")
|
||||
}
|
||||
if string(decryptedToken) != intentID {
|
||||
return errors.ThrowPermissionDenied(nil, "IDP-dkje3", "Errors.Intent.InvalidToken")
|
||||
}
|
||||
return nil
|
||||
return crypto.CheckToken(s.idpAlg, token, intentID)
|
||||
}
|
||||
|
||||
func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) {
|
||||
authMethods, err := s.query.ListActiveUserAuthMethodTypes(ctx, req.GetUserId(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.ListAuthenticationMethodTypesResponse{
|
||||
Details: object.ToListDetails(authMethods.SearchResponse),
|
||||
AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType {
|
||||
methods := make([]user.AuthenticationMethodType, len(methodTypes))
|
||||
for i, method := range methodTypes {
|
||||
methods[i] = authMethodTypeToPb(method)
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType {
|
||||
switch methodType {
|
||||
case domain.UserAuthMethodTypeOTP:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP
|
||||
case domain.UserAuthMethodTypeU2F:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F
|
||||
case domain.UserAuthMethodTypePasswordless:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY
|
||||
case domain.UserAuthMethodTypePassword:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD
|
||||
case domain.UserAuthMethodTypeIDP:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP
|
||||
case domain.UserAuthMethodTypeUnspecified:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
|
||||
default:
|
||||
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
@ -13,15 +13,12 @@ import (
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v2/pkg/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/idp/providers/oauth"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/repository/idp"
|
||||
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
@ -47,60 +44,8 @@ func TestMain(m *testing.M) {
|
||||
}())
|
||||
}
|
||||
|
||||
func createProvider(t *testing.T) string {
|
||||
ctx := authz.WithInstance(context.Background(), Tester.Instance)
|
||||
id, _, err := Tester.Commands.AddOrgGenericOAuthProvider(ctx, Tester.Organisation.ID, command.GenericOAuthProvider{
|
||||
"idp",
|
||||
"clientID",
|
||||
"clientSecret",
|
||||
"https://example.com/oauth/v2/authorize",
|
||||
"https://example.com/oauth/v2/token",
|
||||
"https://api.example.com/user",
|
||||
[]string{"openid", "profile", "email"},
|
||||
"id",
|
||||
idp.Options{
|
||||
IsLinkingAllowed: true,
|
||||
IsCreationAllowed: true,
|
||||
IsAutoCreation: true,
|
||||
IsAutoUpdate: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
func createIntent(t *testing.T, idpID string) string {
|
||||
ctx := authz.WithInstance(context.Background(), Tester.Instance)
|
||||
id, _, err := Tester.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", Tester.Organisation.ID)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
func createSuccessfulIntent(t *testing.T, idpID string) (string, string, time.Time, uint64) {
|
||||
ctx := authz.WithInstance(context.Background(), Tester.Instance)
|
||||
intentID := createIntent(t, idpID)
|
||||
writeModel, err := Tester.Commands.GetIntentWriteModel(ctx, intentID, Tester.Organisation.ID)
|
||||
require.NoError(t, err)
|
||||
idpUser := &oauth.UserMapper{
|
||||
RawInfo: map[string]interface{}{
|
||||
"id": "id",
|
||||
},
|
||||
}
|
||||
idpSession := &oauth.Session{
|
||||
Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: &oauth2.Token{
|
||||
AccessToken: "accessToken",
|
||||
},
|
||||
IDToken: "idToken",
|
||||
},
|
||||
}
|
||||
token, err := Tester.Commands.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, "")
|
||||
require.NoError(t, err)
|
||||
return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence
|
||||
}
|
||||
|
||||
func TestServer_AddHumanUser(t *testing.T) {
|
||||
idpID := createProvider(t)
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.AddHumanUserRequest
|
||||
@ -386,9 +331,9 @@ func TestServer_AddHumanUser(t *testing.T) {
|
||||
},
|
||||
IdpLinks: []*user.IDPLink{
|
||||
{
|
||||
IdpId: "idpID",
|
||||
IdpExternalId: "externalID",
|
||||
DisplayName: "displayName",
|
||||
IdpId: "idpID",
|
||||
UserId: "userID",
|
||||
UserName: "username",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -433,9 +378,9 @@ func TestServer_AddHumanUser(t *testing.T) {
|
||||
},
|
||||
IdpLinks: []*user.IDPLink{
|
||||
{
|
||||
IdpId: idpID,
|
||||
IdpExternalId: "externalID",
|
||||
DisplayName: "displayName",
|
||||
IdpId: idpID,
|
||||
UserId: "userID",
|
||||
UserName: "username",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -477,7 +422,7 @@ func TestServer_AddHumanUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_AddIDPLink(t *testing.T) {
|
||||
idpID := createProvider(t)
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.AddIDPLinkRequest
|
||||
@ -495,9 +440,9 @@ func TestServer_AddIDPLink(t *testing.T) {
|
||||
&user.AddIDPLinkRequest{
|
||||
UserId: "userID",
|
||||
IdpLink: &user.IDPLink{
|
||||
IdpId: idpID,
|
||||
IdpExternalId: "externalID",
|
||||
DisplayName: "displayName",
|
||||
IdpId: idpID,
|
||||
UserId: "userID",
|
||||
UserName: "username",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -511,9 +456,9 @@ func TestServer_AddIDPLink(t *testing.T) {
|
||||
&user.AddIDPLinkRequest{
|
||||
UserId: Tester.Users[integration.OrgOwner].ID,
|
||||
IdpLink: &user.IDPLink{
|
||||
IdpId: "idpID",
|
||||
IdpExternalId: "externalID",
|
||||
DisplayName: "displayName",
|
||||
IdpId: "idpID",
|
||||
UserId: "userID",
|
||||
UserName: "username",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -527,9 +472,9 @@ func TestServer_AddIDPLink(t *testing.T) {
|
||||
&user.AddIDPLinkRequest{
|
||||
UserId: Tester.Users[integration.OrgOwner].ID,
|
||||
IdpLink: &user.IDPLink{
|
||||
IdpId: idpID,
|
||||
IdpExternalId: "externalID",
|
||||
DisplayName: "displayName",
|
||||
IdpId: idpID,
|
||||
UserId: "userID",
|
||||
UserName: "username",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -557,7 +502,7 @@ func TestServer_AddIDPLink(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_StartIdentityProviderFlow(t *testing.T) {
|
||||
idpID := createProvider(t)
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.StartIdentityProviderFlowRequest
|
||||
@ -621,9 +566,9 @@ func TestServer_StartIdentityProviderFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
|
||||
idpID := createProvider(t)
|
||||
intentID := createIntent(t, idpID)
|
||||
successfulID, token, changeDate, sequence := createSuccessfulIntent(t, idpID)
|
||||
idpID := Tester.AddGenericOAuthProvider(t)
|
||||
intentID := Tester.CreateIntent(t, idpID)
|
||||
successfulID, token, changeDate, sequence := Tester.CreateSuccessfulIntent(t, idpID, "", "id")
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.RetrieveIdentityProviderInformationRequest
|
||||
@ -678,7 +623,17 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
|
||||
IdToken: gu.Ptr("idToken"),
|
||||
},
|
||||
},
|
||||
IdpInformation: []byte(`{"RawInfo":{"id":"id"}}`),
|
||||
IdpId: idpID,
|
||||
UserId: "id",
|
||||
UserName: "username",
|
||||
RawInformation: func() *structpb.Struct {
|
||||
s, err := structpb.NewStruct(map[string]interface{}{
|
||||
"sub": "id",
|
||||
"preferred_username": "username",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}(),
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@ -693,8 +648,113 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.want.GetDetails(), got.GetDetails())
|
||||
require.Equal(t, tt.want.GetIdpInformation(), got.GetIdpInformation())
|
||||
grpc.AllFieldsEqual(t, got.ProtoReflect(), tt.want.ProtoReflect(), grpc.CustomMappers)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ListAuthenticationMethodTypes(t *testing.T) {
|
||||
userIDWithoutAuth := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
|
||||
userIDWithPasskey := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
Tester.RegisterUserPasskey(CTX, userIDWithPasskey)
|
||||
|
||||
userMultipleAuth := Tester.CreateHumanUser(CTX).GetUserId()
|
||||
Tester.RegisterUserPasskey(CTX, userMultipleAuth)
|
||||
provider, err := Tester.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{
|
||||
Name: "ListAuthenticationMethodTypes",
|
||||
Issuer: "https://example.com",
|
||||
ClientId: "client_id",
|
||||
ClientSecret: "client_secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
idpLink, err := Tester.Client.UserV2.AddIDPLink(CTX, &user.AddIDPLinkRequest{UserId: userMultipleAuth, IdpLink: &user.IDPLink{
|
||||
IdpId: provider.GetId(),
|
||||
UserId: "external-id",
|
||||
UserName: "displayName",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.ListAuthenticationMethodTypesRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.ListAuthenticationMethodTypesResponse
|
||||
}{
|
||||
{
|
||||
name: "no auth",
|
||||
args: args{
|
||||
CTX,
|
||||
&user.ListAuthenticationMethodTypesRequest{
|
||||
UserId: userIDWithoutAuth,
|
||||
},
|
||||
},
|
||||
want: &user.ListAuthenticationMethodTypesResponse{
|
||||
Details: &object.ListDetails{
|
||||
TotalResult: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with auth (passkey)",
|
||||
args: args{
|
||||
CTX,
|
||||
&user.ListAuthenticationMethodTypesRequest{
|
||||
UserId: userIDWithPasskey,
|
||||
},
|
||||
},
|
||||
want: &user.ListAuthenticationMethodTypesResponse{
|
||||
Details: &object.ListDetails{
|
||||
TotalResult: 1,
|
||||
},
|
||||
AuthMethodTypes: []user.AuthenticationMethodType{
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple auth",
|
||||
args: args{
|
||||
CTX,
|
||||
&user.ListAuthenticationMethodTypesRequest{
|
||||
UserId: userMultipleAuth,
|
||||
},
|
||||
},
|
||||
want: &user.ListAuthenticationMethodTypesResponse{
|
||||
Details: &object.ListDetails{
|
||||
TotalResult: 2,
|
||||
},
|
||||
AuthMethodTypes: []user.AuthenticationMethodType{
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got *user.ListAuthenticationMethodTypesResponse
|
||||
var err error
|
||||
|
||||
for {
|
||||
got, err = Client.ListAuthenticationMethodTypes(tt.args.ctx, tt.args.req)
|
||||
if err == nil && got.GetDetails().GetProcessedSequence() >= idpLink.GetDetails().GetSequence() {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-CTX.Done():
|
||||
t.Fatal(CTX.Err(), err)
|
||||
case <-time.After(time.Second):
|
||||
t.Log("retrying ListAuthenticationMethodTypes")
|
||||
continue
|
||||
}
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want.GetDetails().GetTotalResult(), got.GetDetails().GetTotalResult())
|
||||
require.Equal(t, tt.want.GetAuthMethodTypes(), got.GetAuthMethodTypes())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ import (
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/grpc"
|
||||
@ -21,6 +23,8 @@ import (
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration", "google.protobuf.Struct"}
|
||||
|
||||
func Test_hashedPasswordToCommand(t *testing.T) {
|
||||
type args struct {
|
||||
hashed *user.HashedPassword
|
||||
@ -128,8 +132,10 @@ func Test_intentToIDPInformationPb(t *testing.T) {
|
||||
InstanceID: "instanceID",
|
||||
ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
|
||||
},
|
||||
IDPID: "idpID",
|
||||
IDPUser: []byte(`{"id": "id"}`),
|
||||
IDPID: "idpID",
|
||||
IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
|
||||
IDPUserID: "idpUserID",
|
||||
IDPUserName: "username",
|
||||
IDPAccessToken: &crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
@ -158,8 +164,10 @@ func Test_intentToIDPInformationPb(t *testing.T) {
|
||||
InstanceID: "instanceID",
|
||||
ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
|
||||
},
|
||||
IDPID: "idpID",
|
||||
IDPUser: []byte(`{"id": "id"}`),
|
||||
IDPID: "idpID",
|
||||
IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
|
||||
IDPUserID: "idpUserID",
|
||||
IDPUserName: "username",
|
||||
IDPAccessToken: &crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
@ -184,8 +192,19 @@ func Test_intentToIDPInformationPb(t *testing.T) {
|
||||
Oauth: &user.IDPOAuthAccessInformation{
|
||||
AccessToken: "accessToken",
|
||||
IdToken: gu.Ptr("idToken"),
|
||||
}},
|
||||
IdpInformation: []byte(`{"id": "id"}`),
|
||||
},
|
||||
},
|
||||
IdpId: "idpID",
|
||||
UserId: "idpUserID",
|
||||
UserName: "username",
|
||||
RawInformation: func() *structpb.Struct {
|
||||
s, err := structpb.NewStruct(map[string]interface{}{
|
||||
"userID": "idpUserID",
|
||||
"username": "username",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}(),
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
@ -196,10 +215,82 @@ func Test_intentToIDPInformationPb(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := intentToIDPInformationPb(tt.args.intent, tt.args.alg)
|
||||
require.ErrorIs(t, err, tt.res.err)
|
||||
assert.Equal(t, tt.res.resp, got)
|
||||
grpc.AllFieldsEqual(t, got.ProtoReflect(), tt.res.resp.ProtoReflect(), grpc.CustomMappers)
|
||||
if tt.res.resp != nil {
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_authMethodTypesToPb(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
methodTypes []domain.UserAuthMethodType
|
||||
want []user.AuthenticationMethodType
|
||||
}{
|
||||
{
|
||||
"empty list",
|
||||
nil,
|
||||
[]user.AuthenticationMethodType{},
|
||||
},
|
||||
{
|
||||
"list",
|
||||
[]domain.UserAuthMethodType{
|
||||
domain.UserAuthMethodTypePasswordless,
|
||||
},
|
||||
[]user.AuthenticationMethodType{
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, authMethodTypesToPb(tt.methodTypes), "authMethodTypesToPb(%v)", tt.methodTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_authMethodTypeToPb(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
methodType domain.UserAuthMethodType
|
||||
want user.AuthenticationMethodType
|
||||
}{
|
||||
{
|
||||
"uspecified",
|
||||
domain.UserAuthMethodTypeUnspecified,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED,
|
||||
},
|
||||
{
|
||||
"(t)otp",
|
||||
domain.UserAuthMethodTypeOTP,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP,
|
||||
},
|
||||
{
|
||||
"u2f",
|
||||
domain.UserAuthMethodTypeU2F,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F,
|
||||
},
|
||||
{
|
||||
"passkey",
|
||||
domain.UserAuthMethodTypePasswordless,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
|
||||
},
|
||||
{
|
||||
"password",
|
||||
domain.UserAuthMethodTypePassword,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD,
|
||||
},
|
||||
{
|
||||
"idp",
|
||||
domain.UserAuthMethodTypeIDP,
|
||||
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, authMethodTypeToPb(tt.methodType), "authMethodTypeToPb(%v)", tt.methodType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +53,9 @@ func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*servicep
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if app.State != domain.AppStateActive {
|
||||
return nil, errors.ThrowPreconditionFailed(nil, "SAML-sdaGg", "app is not active")
|
||||
}
|
||||
return serviceprovider.NewServiceProvider(
|
||||
app.ID,
|
||||
&serviceprovider.Config{
|
||||
@ -67,6 +70,9 @@ func (p *Storage) GetEntityIDByAppID(ctx context.Context, appID string) (string,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if app.State != domain.AppStateActive {
|
||||
return "", errors.ThrowPreconditionFailed(nil, "SAML-sdaGg", "app is not active")
|
||||
}
|
||||
return app.SAMLConfig.EntityID, nil
|
||||
}
|
||||
|
||||
|
@ -60,10 +60,10 @@ func (l *Login) handleInitPasswordCheck(w http.ResponseWriter, r *http.Request)
|
||||
l.resendPasswordSet(w, r, authReq)
|
||||
return
|
||||
}
|
||||
l.checkPWCode(w, r, authReq, data, nil)
|
||||
l.checkPWCode(w, r, authReq, data)
|
||||
}
|
||||
|
||||
func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *initPasswordFormData, err error) {
|
||||
func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *initPasswordFormData) {
|
||||
if data.Password != data.PasswordConfirm {
|
||||
err := errors.ThrowInvalidArgument(nil, "VIEW-KaGue", "Errors.User.Password.ConfirmationWrong")
|
||||
l.renderInitPassword(w, r, authReq, data.UserID, data.Code, err)
|
||||
@ -74,12 +74,7 @@ func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *dom
|
||||
userOrg = authReq.UserOrgID
|
||||
}
|
||||
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
|
||||
passwordCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypePasswordResetCode, l.userCodeAlg)
|
||||
if err != nil {
|
||||
l.renderInitPassword(w, r, authReq, data.UserID, "", err)
|
||||
return
|
||||
}
|
||||
err = l.command.SetPasswordWithVerifyCode(setContext(r.Context(), userOrg), userOrg, data.UserID, data.Code, data.Password, userAgentID, passwordCodeGenerator)
|
||||
_, err := l.command.SetPasswordWithVerifyCode(setContext(r.Context(), userOrg), userOrg, data.UserID, data.Code, data.Password, userAgentID)
|
||||
if err != nil {
|
||||
l.renderInitPassword(w, r, authReq, data.UserID, "", err)
|
||||
return
|
||||
|
440
internal/api/ui/login/static/i18n/bg.yaml
Normal file
440
internal/api/ui/login/static/i18n/bg.yaml
Normal file
@ -0,0 +1,440 @@
|
||||
Login:
|
||||
Title: Добре дошъл обратно!
|
||||
Description: Въведете вашите данни за вход.
|
||||
TitleLinking: Влезте за потребителско свързване
|
||||
DescriptionLinking: >-
|
||||
Въведете вашите данни за вход, за да свържете вашия външен потребител с
|
||||
потребител на ZITADEL.
|
||||
LoginNameLabel: Потребителско име
|
||||
UsernamePlaceHolder: потребителско име
|
||||
LoginnamePlaceHolder: потребителско име@домейн
|
||||
ExternalUserDescription: Влезте с външен потребител.
|
||||
MustBeMemberOfOrg: 'Потребителят трябва да е член на {{.OrgName}} организация.'
|
||||
RegisterButtonText: регистрирам
|
||||
NextButtonText: следващия
|
||||
LDAP:
|
||||
Title: Влизам
|
||||
Description: Въведете вашите данни за вход.
|
||||
LoginNameLabel: Потребителско име
|
||||
PasswordLabel: Парола
|
||||
NextButtonText: следващия
|
||||
SelectAccount:
|
||||
Title: Изберете акаунт
|
||||
Description: Използвайте вашия ZITADEL-акаунт
|
||||
TitleLinking: Изберете акаунт за свързване на потребител
|
||||
DescriptionLinking: 'Изберете своя акаунт, който да свържете с вашия външен потребител.'
|
||||
OtherUser: Друг потребител
|
||||
SessionState0: активен
|
||||
SessionState1: неактивен
|
||||
MustBeMemberOfOrg: 'Потребителят трябва да е член на {{.OrgName}} организация.'
|
||||
Password:
|
||||
Title: Парола
|
||||
Description: Въведете вашите данни за вход.
|
||||
PasswordLabel: Парола
|
||||
MinLength: Минимална дължина
|
||||
HasUppercase: Главна буква
|
||||
HasLowercase: Малка буква
|
||||
HasNumber: Номер
|
||||
HasSymbol: Символ
|
||||
Confirmation: Съвпадение за потвърждение
|
||||
ResetLinkText: нулиране на парола
|
||||
BackButtonText: обратно
|
||||
NextButtonText: следващия
|
||||
UsernameChange:
|
||||
Title: Промяна на потребителското име
|
||||
Description: Задайте новото си потребителско име
|
||||
UsernameLabel: Потребителско име
|
||||
CancelButtonText: анулиране
|
||||
NextButtonText: следващия
|
||||
UsernameChangeDone:
|
||||
Title: Потребителското име е променено
|
||||
Description: Вашето потребителско име бе променено успешно.
|
||||
NextButtonText: следващия
|
||||
InitPassword:
|
||||
Title: Задайте парола
|
||||
Description: >-
|
||||
Получихте код, който трябва да въведете във формата по-долу, за да зададете
|
||||
новата си парола.
|
||||
CodeLabel: Код
|
||||
NewPasswordLabel: нова парола
|
||||
NewPasswordConfirmLabel: потвърди парола
|
||||
ResendButtonText: код за препращане
|
||||
NextButtonText: следващия
|
||||
InitPasswordDone:
|
||||
Title: Зададена парола
|
||||
Description: Паролата е зададена успешно
|
||||
NextButtonText: следващия
|
||||
CancelButtonText: анулиране
|
||||
InitUser:
|
||||
Title: Активиране на потребител
|
||||
Description: Потвърдете имейла си с кода по-долу и задайте парола.
|
||||
CodeLabel: Код
|
||||
NewPasswordLabel: нова парола
|
||||
NewPasswordConfirm: потвърди парола
|
||||
NextButtonText: следващия
|
||||
ResendButtonText: код за препращане
|
||||
InitUserDone:
|
||||
Title: Потребителят е активиран
|
||||
Description: Имейлът е потвърден и паролата е успешно зададена
|
||||
NextButtonText: следващия
|
||||
CancelButtonText: анулиране
|
||||
InitMFAPrompt:
|
||||
Title: 2-факторна настройка
|
||||
Description: >-
|
||||
Двуфакторното удостоверяване ви дава допълнителна сигурност за вашия
|
||||
потребителски акаунт.
|
||||
Provider0: 'Приложение за удостоверяване (напр. Google/Microsoft Authenticator, Authy)'
|
||||
Provider1: 'Зависи от устройството (напр. FaceID, Windows Hello, пръстов отпечатък)'
|
||||
NextButtonText: следващия
|
||||
SkipButtonText: пропуснете
|
||||
InitMFAOTP:
|
||||
Title: 2-факторна проверка
|
||||
Description: 'Създайте своя 2-фактор. '
|
||||
OTPDescription: >-
|
||||
Сканирайте кода с вашето приложение за удостоверяване (напр.
|
||||
Google/Microsoft Authenticator, Authy) или копирайте тайната и поставете
|
||||
генерирания код по-долу.
|
||||
SecretLabel: Тайна
|
||||
CodeLabel: Код
|
||||
NextButtonText: следващия
|
||||
CancelButtonText: анулиране
|
||||
InitMFAU2F:
|
||||
Title: Добавете ключ за сигурност
|
||||
Description: >-
|
||||
Ключът за сигурност е метод за потвърждение, който може да бъде вграден във
|
||||
вашия телефон, да използва Bluetooth или да се включи директно в USB порта
|
||||
на вашия компютър.
|
||||
TokenNameLabel: Име на защитния ключ/устройство
|
||||
NotSupported: 'WebAuthN не се поддържа от вашия браузър. '
|
||||
RegisterTokenButtonText: Добавете ключ за сигурност
|
||||
ErrorRetry: >-
|
||||
Опитайте отново, създайте ново предизвикателство или изберете различен
|
||||
метод.
|
||||
InitMFADone:
|
||||
Title: Ключът за сигурност е проверен
|
||||
Description: 'Страхотно! '
|
||||
NextButtonText: следващия
|
||||
CancelButtonText: анулиране
|
||||
MFAProvider:
|
||||
Provider0: 'Приложение за удостоверяване (напр. Google/Microsoft Authenticator, Authy)'
|
||||
Provider1: 'Зависи от устройството (напр. FaceID, Windows Hello, пръстов отпечатък)'
|
||||
ChooseOther: или изберете друга опция
|
||||
VerifyMFAOTP:
|
||||
Title: Проверете 2-фактора
|
||||
Description: Проверете вашия втори фактор
|
||||
CodeLabel: Код
|
||||
NextButtonText: следващия
|
||||
VerifyMFAU2F:
|
||||
Title: 2-факторна проверка
|
||||
Description: >-
|
||||
Потвърдете своя 2-фактор с регистрираното устройство (напр. FaceID, Windows
|
||||
Hello, пръстов отпечатък)
|
||||
NotSupported: 'WebAuthN не се поддържа от вашия браузър. '
|
||||
ErrorRetry: 'Опитайте отново, създайте нова заявка или изберете друг метод.'
|
||||
ValidateTokenButtonText: Проверете 2-фактора
|
||||
Passwordless:
|
||||
Title: Вход без парола
|
||||
Description: >-
|
||||
Влезте с методи за удостоверяване, предоставени от вашето устройство, като
|
||||
FaceID, Windows Hello или пръстов отпечатък.
|
||||
NotSupported: 'WebAuthN не се поддържа от вашия браузър. '
|
||||
ErrorRetry: >-
|
||||
Опитайте отново, създайте ново предизвикателство или изберете различен
|
||||
метод.
|
||||
LoginWithPwButtonText: Влезте с парола
|
||||
ValidateTokenButtonText: Влезте без парола
|
||||
PasswordlessPrompt:
|
||||
Title: Настройка без парола
|
||||
Description: 'Искате ли да настроите влизане без парола? '
|
||||
DescriptionInit: 'Трябва да настроите влизане без парола. '
|
||||
PasswordlessButtonText: Преминете без парола
|
||||
NextButtonText: следващия
|
||||
SkipButtonText: пропуснете
|
||||
PasswordlessRegistration:
|
||||
Title: Настройка без парола
|
||||
Description: >-
|
||||
Добавете вашето удостоверяване, като предоставите име (напр. MyMobilePhone,
|
||||
MacBook и т.н.) и след това щракнете върху бутона „Регистриране без парола“
|
||||
по-долу.
|
||||
TokenNameLabel: Име на устройството
|
||||
NotSupported: 'WebAuthN не се поддържа от вашия браузър. '
|
||||
RegisterTokenButtonText: Регистрирайте се без парола
|
||||
ErrorRetry: >-
|
||||
Опитайте отново, създайте ново предизвикателство или изберете различен
|
||||
метод.
|
||||
PasswordlessRegistrationDone:
|
||||
Title: Настройка без парола
|
||||
Description: Успешно добавено устройство за без парола.
|
||||
DescriptionClose: Сега можете да затворите този прозорец.
|
||||
NextButtonText: следващия
|
||||
CancelButtonText: анулиране
|
||||
PasswordChange:
|
||||
Title: Промяна на паролата
|
||||
Description: 'Променете паролата си. '
|
||||
OldPasswordLabel: Стара парола
|
||||
NewPasswordLabel: нова парола
|
||||
NewPasswordConfirmLabel: Потвърждение на парола
|
||||
CancelButtonText: анулиране
|
||||
NextButtonText: следващия
|
||||
Footer: Долен колонтитул
|
||||
PasswordChangeDone:
|
||||
Title: Промяна на паролата
|
||||
Description: Вашата парола бе променена успешно.
|
||||
NextButtonText: следващия
|
||||
PasswordResetDone:
|
||||
Title: Връзката за повторно задаване на парола е изпратена
|
||||
Description: 'Проверете имейла си, за да нулирате паролата си.'
|
||||
NextButtonText: следващия
|
||||
EmailVerification:
|
||||
Title: Потвърждение на имейла
|
||||
Description: 'Изпратихме ви имейл, за да потвърдим адреса ви. '
|
||||
CodeLabel: Код
|
||||
NextButtonText: следващия
|
||||
ResendButtonText: код за препращане
|
||||
EmailVerificationDone:
|
||||
Title: Потвърждение на имейла
|
||||
Description: Вашият имейл адрес е потвърден успешно.
|
||||
NextButtonText: следващия
|
||||
CancelButtonText: анулиране
|
||||
LoginButtonText: Влизам
|
||||
RegisterOption:
|
||||
Title: Опции за регистрация
|
||||
Description: Изберете как искате да се регистрирате
|
||||
RegisterUsernamePasswordButtonText: С парола за потребителско име
|
||||
ExternalLoginDescription: или се регистрирайте при външен потребител
|
||||
LoginButtonText: Влизам
|
||||
RegistrationUser:
|
||||
Title: Регистрация
|
||||
Description: 'Въведете своите потребителски данни. '
|
||||
DescriptionOrgRegister: Въведете своите потребителски данни.
|
||||
EmailLabel: Електронна поща
|
||||
UsernameLabel: Потребителско име
|
||||
FirstnameLabel: Първо име
|
||||
LastnameLabel: Фамилия
|
||||
LanguageLabel: език
|
||||
German: Deutsch
|
||||
English: Английски
|
||||
Italian: Италиано
|
||||
French: Français
|
||||
Chinese: 简体中文
|
||||
Polish: Полски
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: Пол
|
||||
Female: Женски пол
|
||||
Male: Мъжки
|
||||
Diverse: разнообразен / X
|
||||
PasswordLabel: Парола
|
||||
PasswordConfirmLabel: Потвърждение на парола
|
||||
TosAndPrivacyLabel: Правила и условия
|
||||
TosConfirm: Приемам
|
||||
TosLinkText: TOS
|
||||
PrivacyConfirm: Приемам
|
||||
PrivacyLinkText: политика за поверителност
|
||||
ExternalLogin: или се регистрирайте при външен потребител
|
||||
BackButtonText: Влизам
|
||||
NextButtonText: следващия
|
||||
ExternalRegistrationUserOverview:
|
||||
Title: Регистрация на външен потребител
|
||||
Description: 'Взехме вашите потребителски данни от избрания доставчик. '
|
||||
EmailLabel: Електронна поща
|
||||
UsernameLabel: Потребителско име
|
||||
FirstnameLabel: Първо име
|
||||
LastnameLabel: Фамилия
|
||||
NicknameLabel: Псевдоним
|
||||
PhoneLabel: Телефонен номер
|
||||
LanguageLabel: език
|
||||
German: Deutsch
|
||||
English: Английски
|
||||
Italian: Италиано
|
||||
French: Français
|
||||
Chinese: 简体中文
|
||||
Japanese: 日本語
|
||||
Polish: Полски
|
||||
Spanish: Español
|
||||
TosAndPrivacyLabel: Правила и условия
|
||||
TosConfirm: Приемам
|
||||
TosLinkText: TOS
|
||||
PrivacyConfirm: Приемам
|
||||
PrivacyLinkText: политика за поверителност
|
||||
ExternalLogin: или се регистрирайте при външен потребител
|
||||
BackButtonText: обратно
|
||||
NextButtonText: спаси
|
||||
RegistrationOrg:
|
||||
Title: Регистрация на организация
|
||||
Description: Въведете името на вашата организация и потребителските данни.
|
||||
OrgNameLabel: Наименование на организацията
|
||||
EmailLabel: Електронна поща
|
||||
UsernameLabel: Потребителско име
|
||||
FirstnameLabel: Първо име
|
||||
LastnameLabel: Фамилия
|
||||
PasswordLabel: Парола
|
||||
PasswordConfirmLabel: Потвърждение на парола
|
||||
TosAndPrivacyLabel: Правила и условия
|
||||
TosConfirm: Приемам
|
||||
TosLinkText: TOS
|
||||
PrivacyConfirm: Приемам
|
||||
PrivacyLinkText: политика за поверителност
|
||||
SaveButtonText: Създайте организация
|
||||
LoginSuccess:
|
||||
Title: Успешен вход
|
||||
AutoRedirectDescription: 'Ще бъдете насочени обратно към вашето приложение автоматично. '
|
||||
RedirectedDescription: Сега можете да затворите този прозорец.
|
||||
NextButtonText: следващия
|
||||
LogoutDone:
|
||||
Title: Излязъл
|
||||
Description: Вие излязохте успешно.
|
||||
LoginButtonText: Влизам
|
||||
LinkingUsersDone:
|
||||
Title: Свързване с потребители
|
||||
Description: Свързването с потребители е готово.
|
||||
CancelButtonText: анулиране
|
||||
NextButtonText: следващия
|
||||
ExternalNotFound:
|
||||
Title: Външен потребител не е намерен
|
||||
Description: 'Външен потребител не е намерен. '
|
||||
LinkButtonText: Връзка
|
||||
AutoRegisterButtonText: регистрирам
|
||||
TosAndPrivacyLabel: Правила и условия
|
||||
TosConfirm: Приемам
|
||||
TosLinkText: TOS
|
||||
PrivacyConfirm: Приемам
|
||||
PrivacyLinkText: политика за поверителност
|
||||
German: Deutsch
|
||||
English: Английски
|
||||
Italian: Италиано
|
||||
French: Français
|
||||
Chinese: 简体中文
|
||||
Polish: Полски
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
DeviceAuth:
|
||||
Title: Упълномощаване на устройството
|
||||
UserCode:
|
||||
Label: Потребителски код
|
||||
Description: 'Въведете потребителския код, представен на устройството.'
|
||||
ButtonNext: следващия
|
||||
Action:
|
||||
Description: Дайте достъп до устройството.
|
||||
GrantDevice: сте на път да предоставите устройство
|
||||
AccessToScopes: достъп до следните обхвати
|
||||
Button:
|
||||
Allow: позволява
|
||||
Deny: отричам
|
||||
Done:
|
||||
Description: Свършен.
|
||||
Approved: 'Упълномощаването на устройството е одобрено. '
|
||||
Denied: 'Упълномощаването на устройството е отказано. '
|
||||
Footer:
|
||||
PoweredBy: Задвижвани от
|
||||
Tos: TOS
|
||||
PrivacyPolicy: Политика за поверителност
|
||||
Help: Помогне
|
||||
SupportEmail: Поддръжка на имейл
|
||||
Errors:
|
||||
Internal: Възникна вътрешна грешка
|
||||
AuthRequest:
|
||||
NotFound: Не може да се намери authrequest
|
||||
UserAgentNotCorresponding: Потребителският агент не отговаря
|
||||
UserAgentNotFound: ID на потребителски агент не е намерен
|
||||
TokenNotFound: Токенът не е намерен
|
||||
RequestTypeNotSupported: Типът заявка не се поддържа
|
||||
MissingParameters: Липсват задължителни параметри
|
||||
User:
|
||||
NotFound: Потребителят не може да бъде намерен
|
||||
AlreadyExists: Вече съществува потребител
|
||||
Inactive: Потребителят е неактивен
|
||||
NotFoundOnOrg: Потребителят не може да бъде намерен в избраната организация
|
||||
NotAllowedOrg: Потребителят не е член на необходимата организация
|
||||
NotMatchingUserID: Потребителят и потребителят в заявката за удостоверяване не съвпадат
|
||||
UserIDMissing: UserID е празен
|
||||
Invalid: Невалидни потребителски данни
|
||||
DomainNotAllowedAsUsername: Домейнът вече е резервиран и не може да се използва
|
||||
NotAllowedToLink: Потребителят няма право да се свързва с външен доставчик на данни за вход
|
||||
Profile:
|
||||
NotFound: Профилът не е намерен
|
||||
NotChanged: Профилът не е променен
|
||||
Empty: Профилът е празен
|
||||
FirstNameEmpty: Първото име в профила е празно
|
||||
LastNameEmpty: Фамилията в профила е празна
|
||||
IDMissing: Липсва ID на потребителския профил
|
||||
Email:
|
||||
NotFound: Имейлът не е намерен
|
||||
Invalid: Имейлът е невалиден
|
||||
AlreadyVerified: Имейлът вече е потвърден
|
||||
NotChanged: Имейлът не е променен
|
||||
Empty: Имейлът е празен
|
||||
IDMissing: Имейл ID липсва
|
||||
Phone:
|
||||
NotFound: Телефонът не е намерен
|
||||
Invalid: Телефонът е невалиден
|
||||
AlreadyVerified: Телефонът вече е потвърден
|
||||
Empty: Телефонът е празен
|
||||
NotChanged: Телефонът не е сменен
|
||||
Address:
|
||||
NotFound: Адресът не е намерен
|
||||
NotChanged: Адресът не е променен
|
||||
Username:
|
||||
AlreadyExists: Потребителско име вече е заето
|
||||
Reserved: Потребителско име вече е заето
|
||||
Empty: Потребителското име е празно
|
||||
Password:
|
||||
ConfirmationWrong: Потвърждението на паролата е грешно
|
||||
Empty: Паролата е празна
|
||||
Invalid: Паролата е невалидна
|
||||
InvalidAndLocked: >-
|
||||
Паролата е невалидна и потребителят е заключен, свържете се с вашия
|
||||
администратор.
|
||||
UsernameOrPassword:
|
||||
Invalid: Потребителското име или паролата са невалидни
|
||||
PasswordComplexityPolicy:
|
||||
NotFound: Политиката за парола не е намерена
|
||||
MinLength: Паролата е твърде кратка
|
||||
HasLower: Паролата трябва да съдържа малка буква
|
||||
HasUpper: Паролата трябва да съдържа горна буква
|
||||
HasNumber: Паролата трябва да съдържа число
|
||||
HasSymbol: Паролата трябва да съдържа символ
|
||||
Code:
|
||||
Expired: Кодът е изтекъл
|
||||
Invalid: Кодът е невалиден
|
||||
Empty: Кодът е празен
|
||||
CryptoCodeNil: Крипто кодът е нула
|
||||
NotFound: Не може да се намери код
|
||||
GeneratorAlgNotSupported: Неподдържан генераторен алгоритъм
|
||||
EmailVerify:
|
||||
UserIDEmpty: UserID е празен
|
||||
ExternalData:
|
||||
CouldNotRead: Външните данни не могат да бъдат прочетени правилно
|
||||
MFA:
|
||||
NoProviders: Няма налични многофакторни доставчици
|
||||
OTP:
|
||||
AlreadyReady: Многофакторният OTP (OneTimePassword) вече е настроен
|
||||
NotExisting: Многофакторният OTP (OneTimePassword) не съществува
|
||||
InvalidCode: Невалиден код
|
||||
NotReady: Многофакторният OTP (OneTimePassword) не е готов
|
||||
Locked: Потребителят е заключен
|
||||
SomethingWentWrong: Нещо се обърка
|
||||
NotActive: Потребителят не е активен
|
||||
ExternalIDP:
|
||||
IDPTypeNotImplemented: Типът IDP не е внедрен
|
||||
NotAllowed: Външен доставчик на вход не е разрешен
|
||||
IDPConfigIDEmpty: Идентификационният номер на доставчика е празен
|
||||
ExternalUserIDEmpty: ID на външен потребител е празен
|
||||
UserDisplayNameEmpty: Екранното име на потребителя е празно
|
||||
NoExternalUserData: Не са получени външни потребителски данни
|
||||
CreationNotAllowed: Създаването на нов потребител не е разрешено на този доставчик
|
||||
LinkingNotAllowed: Свързването на потребител не е разрешено на този доставчик
|
||||
GrantRequired: 'Влизането не е възможно. '
|
||||
ProjectRequired: 'Влизането не е възможно. '
|
||||
IdentityProvider:
|
||||
InvalidConfig: Конфигурацията на доставчика на самоличност е невалидна
|
||||
IAM:
|
||||
LockoutPolicy:
|
||||
NotExisting: Политиката за блокиране не съществува
|
||||
Org:
|
||||
LoginPolicy:
|
||||
RegistrationNotAllowed: Регистрацията не е разрешена
|
||||
DeviceAuth:
|
||||
NotExisting: Потребителският код не съществува
|
||||
optional: (по избор)
|
@ -222,6 +222,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: Geschlecht
|
||||
Female: weiblich
|
||||
Male: männlich
|
||||
|
@ -316,7 +316,7 @@ ExternalNotFound:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
|
||||
Bulgarian: Български
|
||||
DeviceAuth:
|
||||
Title: Device Authorization
|
||||
UserCode:
|
||||
|
@ -222,6 +222,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: Género
|
||||
Female: Mujer
|
||||
Male: Hombre
|
||||
|
@ -60,7 +60,7 @@ InitPassword:
|
||||
CodeLabel: Code
|
||||
NewPasswordLabel: Nouveau mot de passe
|
||||
NewPasswordConfirmLabel: Confirmer le mot de passe
|
||||
ResendButtonText: code de réexpédition
|
||||
ResendButtonText: renvoyer le code
|
||||
NextButtonText: suivant
|
||||
|
||||
InitPasswordDone:
|
||||
@ -76,7 +76,7 @@ InitUser:
|
||||
NewPasswordLabel: Nouveau mot de passe
|
||||
NewPasswordConfirm: Confirmer le mot de passe
|
||||
NextButtonText: Suivant
|
||||
ResendButtonText: code de réexpédition
|
||||
ResendButtonText: renvoyer le code
|
||||
|
||||
InitUserDone:
|
||||
Title: User Utilisateur activé
|
||||
@ -189,7 +189,7 @@ EmailVerification:
|
||||
Description: Nous vous avons envoyé un e-mail pour vérifier votre adresse. Veuillez saisir le code dans le formulaire ci-dessous.
|
||||
CodeLabel: Code
|
||||
NextButtonText: suivant
|
||||
ResendButtonText: code de réexpédition
|
||||
ResendButtonText: renvoyer le code
|
||||
|
||||
EmailVerificationDone:
|
||||
Title: E-Mail Verification
|
||||
@ -222,6 +222,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: Genre
|
||||
Female: Femme
|
||||
Male: Homme
|
||||
|
@ -222,6 +222,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: Genere
|
||||
Female: Femminile
|
||||
Male: Maschile
|
||||
|
@ -214,6 +214,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: 性別
|
||||
Female: 女性
|
||||
Male: 男性
|
||||
|
@ -222,6 +222,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: Płeć
|
||||
Female: Kobieta
|
||||
Male: Mężczyzna
|
||||
|
@ -222,6 +222,7 @@ RegistrationUser:
|
||||
Polish: Polski
|
||||
Japanese: 日本語
|
||||
Spanish: Español
|
||||
Bulgarian: Български
|
||||
GenderLabel: 性别
|
||||
Female: 女性
|
||||
Male: 男性
|
||||
|
@ -76,6 +76,8 @@
|
||||
</option>
|
||||
<option value="zh" id="zh" {{if (selectedLanguage "zh")}} selected {{end}}>{{t "ExternalNotFound.Chinese"}}
|
||||
</option>
|
||||
<option value="bg" id="bg" {{if (selectedLanguage "bg")}} selected {{end}}>{{t "ExternalNotFound.Bulgarian"}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -119,17 +119,16 @@ func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWr
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd, err := idpintent.NewSucceededEvent(
|
||||
cmd := idpintent.NewSucceededEvent(
|
||||
ctx,
|
||||
&idpintent.NewAggregate(writeModel.AggregateID, writeModel.ResourceOwner).Aggregate,
|
||||
idpInfo,
|
||||
idpUser.GetID(),
|
||||
idpUser.GetPreferredUsername(),
|
||||
userID,
|
||||
accessToken,
|
||||
idToken,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = c.pushAppendAndReduce(ctx, writeModel, cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
@ -16,6 +16,8 @@ type IDPIntentWriteModel struct {
|
||||
FailureURL *url.URL
|
||||
IDPID string
|
||||
IDPUser []byte
|
||||
IDPUserID string
|
||||
IDPUserName string
|
||||
IDPAccessToken *crypto.CryptoValue
|
||||
IDPIDToken string
|
||||
UserID string
|
||||
@ -72,6 +74,8 @@ func (wm *IDPIntentWriteModel) reduceStartedEvent(e *idpintent.StartedEvent) {
|
||||
func (wm *IDPIntentWriteModel) reduceSucceededEvent(e *idpintent.SucceededEvent) {
|
||||
wm.UserID = e.UserID
|
||||
wm.IDPUser = e.IDPUser
|
||||
wm.IDPUserID = e.IDPUserID
|
||||
wm.IDPUserName = e.IDPUserName
|
||||
wm.IDPAccessToken = e.IDPAccessToken
|
||||
wm.IDPIDToken = e.IDPIDToken
|
||||
wm.State = domain.IDPIntentStateSucceeded
|
||||
|
@ -435,22 +435,21 @@ func TestCommands_SucceedIDPIntent(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectPush(
|
||||
eventPusherToEvents(
|
||||
func() eventstore.Command {
|
||||
event, _ := idpintent.NewSucceededEvent(
|
||||
context.Background(),
|
||||
&idpintent.NewAggregate("id", "ro").Aggregate,
|
||||
[]byte(`{"RawInfo":{"id":"id"}}`),
|
||||
"",
|
||||
&crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
KeyID: "id",
|
||||
Crypted: []byte("accessToken"),
|
||||
},
|
||||
"",
|
||||
)
|
||||
return event
|
||||
}(),
|
||||
idpintent.NewSucceededEvent(
|
||||
context.Background(),
|
||||
&idpintent.NewAggregate("id", "ro").Aggregate,
|
||||
[]byte(`{"sub":"id","preferred_username":"username"}`),
|
||||
"id",
|
||||
"username",
|
||||
"",
|
||||
&crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
KeyID: "id",
|
||||
Crypted: []byte("accessToken"),
|
||||
},
|
||||
"idToken",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -458,18 +457,20 @@ func TestCommands_SucceedIDPIntent(t *testing.T) {
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
writeModel: NewIDPIntentWriteModel("id", "ro"),
|
||||
idpSession: &oauth.Session{
|
||||
idpSession: &openid.Session{
|
||||
Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: &oauth2.Token{
|
||||
AccessToken: "accessToken",
|
||||
},
|
||||
IDToken: "idToken",
|
||||
},
|
||||
},
|
||||
idpUser: &oauth.UserMapper{
|
||||
RawInfo: map[string]interface{}{
|
||||
"id": "id",
|
||||
idpUser: openid.NewUser(&oidc.UserInfo{
|
||||
Subject: "id",
|
||||
UserInfoProfile: oidc.UserInfoProfile{
|
||||
PreferredUsername: "username",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
res{
|
||||
token: "aWQ",
|
||||
|
@ -46,6 +46,12 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore {
|
||||
return es
|
||||
}
|
||||
|
||||
func expectEventstore(expects ...expect) func(*testing.T) *eventstore.Eventstore {
|
||||
return func(t *testing.T) *eventstore.Eventstore {
|
||||
return eventstoreExpect(t, expects...)
|
||||
}
|
||||
}
|
||||
|
||||
func eventPusherToEvents(eventsPushes ...eventstore.Command) []*repository.Event {
|
||||
events := make([]*repository.Event, len(eventsPushes))
|
||||
for i, event := range eventsPushes {
|
||||
@ -125,6 +131,18 @@ func expectPushFailed(err error, events []*repository.Event, uniqueConstraints .
|
||||
}
|
||||
}
|
||||
|
||||
func expectRandomPush(events []*repository.Event, uniqueConstraints ...*repository.UniqueConstraint) expect {
|
||||
return func(m *mock.MockRepository) {
|
||||
m.ExpectRandomPush(events, uniqueConstraints...)
|
||||
}
|
||||
}
|
||||
|
||||
func expectRandomPushFailed(err error, events []*repository.Event, uniqueConstraints ...*repository.UniqueConstraint) expect {
|
||||
return func(m *mock.MockRepository) {
|
||||
m.ExpectRandomPushFailed(err, events, uniqueConstraints...)
|
||||
}
|
||||
}
|
||||
|
||||
func expectFilter(events ...*repository.Event) expect {
|
||||
return func(m *mock.MockRepository) {
|
||||
m.ExpectFilterEvents(events...)
|
||||
|
@ -23,8 +23,10 @@ type SessionCommands struct {
|
||||
|
||||
sessionWriteModel *SessionWriteModel
|
||||
passwordWriteModel *HumanPasswordWriteModel
|
||||
intentWriteModel *IDPIntentWriteModel
|
||||
eventstore *eventstore.Eventstore
|
||||
userPasswordAlg crypto.HashAlgorithm
|
||||
intentAlg crypto.EncryptionAlgorithm
|
||||
createToken func(sessionID string) (id string, token string, err error)
|
||||
now func() time.Time
|
||||
}
|
||||
@ -35,6 +37,7 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri
|
||||
sessionWriteModel: session,
|
||||
eventstore: c.eventstore,
|
||||
userPasswordAlg: c.userPasswordAlg,
|
||||
intentAlg: c.idpConfigEncryption,
|
||||
createToken: c.sessionTokenCreator,
|
||||
now: time.Now,
|
||||
}
|
||||
@ -80,6 +83,42 @@ func CheckPassword(password string) SessionCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// CheckIntent defines a check for a succeeded intent to be executed for a session update
|
||||
func CheckIntent(intentID, token string) SessionCommand {
|
||||
return func(ctx context.Context, cmd *SessionCommands) error {
|
||||
if cmd.sessionWriteModel.UserID == "" {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3r", "Errors.User.UserIDMissing")
|
||||
}
|
||||
if err := crypto.CheckToken(cmd.intentAlg, token, intentID); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.intentWriteModel = NewIDPIntentWriteModel(intentID, "")
|
||||
err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.intentWriteModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.intentWriteModel.State != domain.IDPIntentStateSucceeded {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4bw", "Errors.Intent.NotSucceeded")
|
||||
}
|
||||
if cmd.intentWriteModel.UserID != "" {
|
||||
if cmd.intentWriteModel.UserID != cmd.sessionWriteModel.UserID {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser")
|
||||
}
|
||||
} else {
|
||||
linkWriteModel := NewUserIDPLinkWriteModel(cmd.sessionWriteModel.UserID, cmd.intentWriteModel.IDPID, cmd.intentWriteModel.IDPUserID, cmd.intentWriteModel.ResourceOwner)
|
||||
err := cmd.eventstore.FilterToQueryReducer(ctx, linkWriteModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if linkWriteModel.State != domain.UserIDPLinkStateActive {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser")
|
||||
}
|
||||
}
|
||||
cmd.sessionWriteModel.IntentChecked(ctx, cmd.now())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Exec will execute the commands specified and returns an error on the first occurrence
|
||||
func (s *SessionCommands) Exec(ctx context.Context) error {
|
||||
for _, cmd := range s.cmds {
|
||||
@ -118,7 +157,7 @@ func (s *SessionCommands) commands(ctx context.Context) (string, []eventstore.Co
|
||||
return token, s.sessionWriteModel.commands, nil
|
||||
}
|
||||
|
||||
func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, metadata map[string][]byte) (set *SessionChanged, err error) {
|
||||
func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, sessionDomain string, metadata map[string][]byte) (set *SessionChanged, err error) {
|
||||
sessionID, err := c.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -128,8 +167,8 @@ func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, met
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionWriteModel.Start(ctx, sessionDomain)
|
||||
cmd := c.NewSessionCommands(cmds, sessionWriteModel)
|
||||
cmd.sessionWriteModel.Start(ctx)
|
||||
return c.updateSession(ctx, cmd, metadata)
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,7 @@ type PasskeyChallengeModel struct {
|
||||
Challenge string
|
||||
AllowedCrentialIDs [][]byte
|
||||
UserVerification domain.UserVerificationRequirement
|
||||
RPID string
|
||||
}
|
||||
|
||||
func (p *PasskeyChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) (*domain.WebAuthNLogin, error) {
|
||||
@ -28,6 +29,7 @@ func (p *PasskeyChallengeModel) WebAuthNLogin(human *domain.Human, credentialAss
|
||||
Challenge: p.Challenge,
|
||||
AllowedCredentialIDs: p.AllowedCrentialIDs,
|
||||
UserVerification: p.UserVerification,
|
||||
RPID: p.RPID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -38,8 +40,10 @@ type SessionWriteModel struct {
|
||||
UserID string
|
||||
UserCheckedAt time.Time
|
||||
PasswordCheckedAt time.Time
|
||||
IntentCheckedAt time.Time
|
||||
PasskeyCheckedAt time.Time
|
||||
Metadata map[string][]byte
|
||||
Domain string
|
||||
State domain.SessionState
|
||||
|
||||
PasskeyChallenge *PasskeyChallengeModel
|
||||
@ -68,6 +72,8 @@ func (wm *SessionWriteModel) Reduce() error {
|
||||
wm.reduceUserChecked(e)
|
||||
case *session.PasswordCheckedEvent:
|
||||
wm.reducePasswordChecked(e)
|
||||
case *session.IntentCheckedEvent:
|
||||
wm.reduceIntentChecked(e)
|
||||
case *session.PasskeyChallengedEvent:
|
||||
wm.reducePasskeyChallenged(e)
|
||||
case *session.PasskeyCheckedEvent:
|
||||
@ -90,6 +96,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
|
||||
session.AddedType,
|
||||
session.UserCheckedType,
|
||||
session.PasswordCheckedType,
|
||||
session.IntentCheckedType,
|
||||
session.PasskeyChallengedType,
|
||||
session.PasskeyCheckedType,
|
||||
session.TokenSetType,
|
||||
@ -105,6 +112,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) reduceAdded(e *session.AddedEvent) {
|
||||
wm.Domain = e.Domain
|
||||
wm.State = domain.SessionStateActive
|
||||
}
|
||||
|
||||
@ -117,11 +125,16 @@ func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEve
|
||||
wm.PasswordCheckedAt = e.CheckedAt
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) reduceIntentChecked(e *session.IntentCheckedEvent) {
|
||||
wm.IntentCheckedAt = e.CheckedAt
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) reducePasskeyChallenged(e *session.PasskeyChallengedEvent) {
|
||||
wm.PasskeyChallenge = &PasskeyChallengeModel{
|
||||
Challenge: e.Challenge,
|
||||
AllowedCrentialIDs: e.AllowedCrentialIDs,
|
||||
UserVerification: e.UserVerification,
|
||||
RPID: wm.Domain,
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,8 +151,10 @@ func (wm *SessionWriteModel) reduceTerminate() {
|
||||
wm.State = domain.SessionStateTerminated
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) Start(ctx context.Context) {
|
||||
wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate))
|
||||
func (wm *SessionWriteModel) Start(ctx context.Context, domain string) {
|
||||
wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate, domain))
|
||||
// set the domain so checks can use it
|
||||
wm.Domain = domain
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error {
|
||||
@ -153,6 +168,10 @@ func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time
|
||||
wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt))
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) IntentChecked(ctx context.Context, checkedAt time.Time) {
|
||||
wm.commands = append(wm.commands, session.NewIntentCheckedEvent(ctx, wm.aggregate, checkedAt))
|
||||
}
|
||||
|
||||
func (wm *SessionWriteModel) PasskeyChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement) {
|
||||
wm.commands = append(wm.commands, session.NewPasskeyChallengedEvent(ctx, wm.aggregate, challenge, allowedCrentialIDs, userVerification))
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ func (c *Commands) CreatePasskeyChallenge(userVerification domain.UserVerificati
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, humanPasskeys.human, userVerification, humanPasskeys.tokens...)
|
||||
webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, humanPasskeys.human, userVerification, cmd.sessionWriteModel.Domain, humanPasskeys.tokens...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ func TestSessionCommands_getHumanPasskeys(t *testing.T) {
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush(
|
||||
context.Background(), &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType,
|
||||
), "111", "challenge"),
|
||||
), "111", "challenge", "rpID"),
|
||||
)),
|
||||
),
|
||||
sessionWriteModel: &SessionWriteModel{
|
||||
@ -112,6 +112,7 @@ func TestSessionCommands_getHumanPasskeys(t *testing.T) {
|
||||
WebAuthNTokenID: "111",
|
||||
State: domain.MFAStateNotReady,
|
||||
Challenge: "challenge",
|
||||
RPID: "rpID",
|
||||
}},
|
||||
},
|
||||
err: nil,
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
"github.com/zitadel/zitadel/internal/id/mock"
|
||||
"github.com/zitadel/zitadel/internal/repository/idpintent"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
)
|
||||
@ -146,6 +147,7 @@ func TestCommands_CreateSession(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
checks []SessionCommand
|
||||
domain string
|
||||
metadata map[string][]byte
|
||||
}
|
||||
type res struct {
|
||||
@ -193,7 +195,7 @@ func TestCommands_CreateSession(t *testing.T) {
|
||||
expectFilter(),
|
||||
expectPush(
|
||||
eventPusherToEvents(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, ""),
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID",
|
||||
),
|
||||
@ -217,6 +219,39 @@ func TestCommands_CreateSession(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"empty session with domain",
|
||||
fields{
|
||||
idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"),
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(),
|
||||
expectPush(
|
||||
eventPusherToEvents(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld"),
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
tokenCreator: func(sessionID string) (string, string, error) {
|
||||
return "tokenID",
|
||||
"token",
|
||||
nil
|
||||
},
|
||||
},
|
||||
args{
|
||||
ctx: authz.NewMockContext("", "org1", ""),
|
||||
domain: "domain.tld",
|
||||
},
|
||||
res{
|
||||
want: &SessionChanged{
|
||||
ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"},
|
||||
ID: "sessionID",
|
||||
NewToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
// the rest is tested in the Test_updateSession
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@ -226,7 +261,7 @@ func TestCommands_CreateSession(t *testing.T) {
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
sessionTokenCreator: tt.fields.tokenCreator,
|
||||
}
|
||||
got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.metadata)
|
||||
got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.domain, tt.args.metadata)
|
||||
require.ErrorIs(t, err, tt.res.err)
|
||||
assert.Equal(t, tt.res.want, got)
|
||||
})
|
||||
@ -275,7 +310,7 @@ func TestCommands_UpdateSession(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")),
|
||||
eventFromEventPusher(
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID")),
|
||||
@ -300,7 +335,7 @@ func TestCommands_UpdateSession(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")),
|
||||
eventFromEventPusher(
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID")),
|
||||
@ -341,6 +376,19 @@ func TestCommands_UpdateSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCommands_updateSession(t *testing.T) {
|
||||
decryption := func(err error) crypto.EncryptionAlgorithm {
|
||||
mCrypto := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t))
|
||||
mCrypto.EXPECT().EncryptionKeyID().Return("id")
|
||||
mCrypto.EXPECT().DecryptString(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(code []byte, keyID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(code), nil
|
||||
})
|
||||
return mCrypto
|
||||
}
|
||||
|
||||
testNow := time.Now()
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
@ -484,6 +532,194 @@ func TestCommands_updateSession(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"set user, intent not successful",
|
||||
fields{
|
||||
eventstore: eventstoreExpect(t),
|
||||
},
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
checks: &SessionCommands{
|
||||
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
|
||||
cmds: []SessionCommand{
|
||||
CheckUser("userID"),
|
||||
CheckIntent("intent", "aW50ZW50"),
|
||||
},
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
|
||||
"username", "", "", "", "", language.English, domain.GenderUnspecified, "", false),
|
||||
),
|
||||
),
|
||||
),
|
||||
createToken: func(sessionID string) (string, string, error) {
|
||||
return "tokenID",
|
||||
"token",
|
||||
nil
|
||||
},
|
||||
intentAlg: decryption(nil),
|
||||
now: func() time.Time {
|
||||
return testNow
|
||||
},
|
||||
},
|
||||
metadata: map[string][]byte{
|
||||
"key": []byte("value"),
|
||||
},
|
||||
},
|
||||
res{
|
||||
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4bw", "Errors.Intent.NotSucceeded"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"set user, intent not for user",
|
||||
fields{
|
||||
eventstore: eventstoreExpect(t),
|
||||
},
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
checks: &SessionCommands{
|
||||
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
|
||||
cmds: []SessionCommand{
|
||||
CheckUser("userID"),
|
||||
CheckIntent("intent", "aW50ZW50"),
|
||||
},
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
|
||||
"username", "", "", "", "", language.English, domain.GenderUnspecified, "", false),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
idpintent.NewSucceededEvent(context.Background(), &idpintent.NewAggregate("intent", "org1").Aggregate,
|
||||
nil,
|
||||
"idpUserID",
|
||||
"idpUserName",
|
||||
"userID2",
|
||||
nil,
|
||||
"",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
createToken: func(sessionID string) (string, string, error) {
|
||||
return "tokenID",
|
||||
"token",
|
||||
nil
|
||||
},
|
||||
intentAlg: decryption(nil),
|
||||
now: func() time.Time {
|
||||
return testNow
|
||||
},
|
||||
},
|
||||
metadata: map[string][]byte{
|
||||
"key": []byte("value"),
|
||||
},
|
||||
},
|
||||
res{
|
||||
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"set user, intent incorrect token",
|
||||
fields{
|
||||
eventstore: eventstoreExpect(t),
|
||||
},
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
checks: &SessionCommands{
|
||||
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
|
||||
cmds: []SessionCommand{
|
||||
CheckUser("userID"),
|
||||
CheckIntent("intent2", "aW50ZW50"),
|
||||
},
|
||||
eventstore: eventstoreExpect(t),
|
||||
createToken: func(sessionID string) (string, string, error) {
|
||||
return "tokenID",
|
||||
"token",
|
||||
nil
|
||||
},
|
||||
intentAlg: decryption(nil),
|
||||
now: func() time.Time {
|
||||
return testNow
|
||||
},
|
||||
},
|
||||
metadata: map[string][]byte{
|
||||
"key": []byte("value"),
|
||||
},
|
||||
},
|
||||
res{
|
||||
err: caos_errs.ThrowPermissionDenied(nil, "CRYPTO-CRYPTO", "Errors.Intent.InvalidToken"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"set user, intent, metadata and token",
|
||||
fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectPush(
|
||||
eventPusherToEvents(
|
||||
session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"userID", testNow),
|
||||
session.NewIntentCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
testNow),
|
||||
session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
map[string][]byte{"key": []byte("value")}),
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID"),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
checks: &SessionCommands{
|
||||
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
|
||||
cmds: []SessionCommand{
|
||||
CheckUser("userID"),
|
||||
CheckIntent("intent", "aW50ZW50"),
|
||||
},
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
|
||||
"username", "", "", "", "", language.English, domain.GenderUnspecified, "", false),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
idpintent.NewSucceededEvent(context.Background(), &idpintent.NewAggregate("intent", "org1").Aggregate,
|
||||
nil,
|
||||
"idpUserID",
|
||||
"idpUsername",
|
||||
"userID",
|
||||
nil,
|
||||
"",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
createToken: func(sessionID string) (string, string, error) {
|
||||
return "tokenID",
|
||||
"token",
|
||||
nil
|
||||
},
|
||||
intentAlg: decryption(nil),
|
||||
now: func() time.Time {
|
||||
return testNow
|
||||
},
|
||||
},
|
||||
metadata: map[string][]byte{
|
||||
"key": []byte("value"),
|
||||
},
|
||||
},
|
||||
res{
|
||||
want: &SessionChanged{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
ID: "sessionID",
|
||||
NewToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@ -537,7 +773,7 @@ func TestCommands_TerminateSession(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")),
|
||||
eventFromEventPusher(
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID")),
|
||||
@ -562,7 +798,7 @@ func TestCommands_TerminateSession(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")),
|
||||
eventFromEventPusher(
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID")),
|
||||
@ -591,7 +827,7 @@ func TestCommands_TerminateSession(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")),
|
||||
eventFromEventPusher(
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID"),
|
||||
@ -622,7 +858,7 @@ func TestCommands_TerminateSession(t *testing.T) {
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
|
||||
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, "domain.tld")),
|
||||
eventFromEventPusher(
|
||||
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
|
||||
"tokenID"),
|
||||
|
@ -140,6 +140,7 @@ func writeModelToWebAuthN(wm *HumanWebAuthNWriteModel) *domain.WebAuthNToken {
|
||||
SignCount: wm.SignCount,
|
||||
WebAuthNTokenName: wm.WebAuthNTokenName,
|
||||
State: wm.State,
|
||||
RPID: wm.RPID,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,12 +3,14 @@ package command
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
@ -43,7 +45,32 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
|
||||
if userID == "" {
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing")
|
||||
}
|
||||
human, err := c.getHuman(ctx, userID, resourceowner)
|
||||
prep, err := c.createHumanTOTP(ctx, userID, resourceowner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = c.eventstore.Push(ctx, prep.cmds...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain.OTP{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
AggregateID: prep.userAgg.ID,
|
||||
},
|
||||
SecretString: prep.key.Secret(),
|
||||
Url: prep.key.URL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type preparedTOTP struct {
|
||||
wm *HumanOTPWriteModel
|
||||
userAgg *eventstore.Aggregate
|
||||
key *otp.Key
|
||||
cmds []eventstore.Command
|
||||
}
|
||||
|
||||
func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner string) (*preparedTOTP, error) {
|
||||
human, err := c.getHuman(ctx, userID, resourceOwner)
|
||||
if err != nil {
|
||||
logging.Log("COMMAND-DAqe1").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname")
|
||||
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-MM9fs", "Errors.User.NotFound")
|
||||
@ -59,7 +86,7 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
|
||||
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound")
|
||||
}
|
||||
|
||||
otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceowner)
|
||||
otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -80,16 +107,13 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = c.eventstore.Push(ctx, user.NewHumanOTPAddedEvent(ctx, userAgg, secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain.OTP{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
AggregateID: human.AggregateID,
|
||||
return &preparedTOTP{
|
||||
wm: otpWriteModel,
|
||||
userAgg: userAgg,
|
||||
key: key,
|
||||
cmds: []eventstore.Command{
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
},
|
||||
SecretString: key.Secret(),
|
||||
Url: key.URL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -2,11 +2,17 @@ package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
@ -231,6 +237,476 @@ func TestCommandSide_AddHumanOTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_createHumanOTP(t *testing.T) {
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
userID string
|
||||
resourceOwner string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "user not existing, not found error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-MM9fs", "Errors.User.NotFound"),
|
||||
},
|
||||
{
|
||||
name: "org not existing, not found error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-55M9f", "Errors.Org.NotFound"),
|
||||
},
|
||||
{
|
||||
name: "org iam policy not existing, not found error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewOrgAddedEvent(context.Background(),
|
||||
&user.NewAggregate("org1", "org1").Aggregate,
|
||||
"org",
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(),
|
||||
expectFilter(),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound"),
|
||||
},
|
||||
{
|
||||
name: "otp already exists, already exists error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewOrgAddedEvent(context.Background(),
|
||||
&user.NewAggregate("org1", "org1").Aggregate,
|
||||
"org",
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewDomainPolicyAddedEvent(context.Background(),
|
||||
&org.NewAggregate("org1").Aggregate,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
&crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
KeyID: "id",
|
||||
Crypted: []byte("a"),
|
||||
}),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"agent1")),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowAlreadyExists(nil, "COMMAND-do9se", "Errors.User.MFA.OTP.AlreadyReady"),
|
||||
},
|
||||
{
|
||||
name: "issuer not in context",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewOrgAddedEvent(context.Background(),
|
||||
&user.NewAggregate("org1", "org1").Aggregate,
|
||||
"org",
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewDomainPolicyAddedEvent(context.Background(),
|
||||
&org.NewAggregate("org1").Aggregate,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInternal(nil, "TOTP-ieY3o", "Errors.Internal"),
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewOrgAddedEvent(context.Background(),
|
||||
&user.NewAggregate("org1", "org1").Aggregate,
|
||||
"org",
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
org.NewDomainPolicyAddedEvent(context.Background(),
|
||||
&org.NewAggregate("org1").Aggregate,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
),
|
||||
),
|
||||
expectFilter(),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
ctx: authz.WithRequestedDomain(context.Background(), "zitadel.com"),
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
multifactors: domain.MultifactorConfigs{
|
||||
OTP: domain.OTPConfig{
|
||||
CryptoMFA: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := c.createHumanTOTP(tt.args.ctx, tt.args.userID, tt.args.resourceOwner)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if tt.want {
|
||||
require.NotNil(t, got)
|
||||
assert.NotNil(t, got.wm)
|
||||
assert.NotNil(t, got.userAgg)
|
||||
require.NotNil(t, got.key)
|
||||
assert.NotEmpty(t, got.key.URL())
|
||||
assert.NotEmpty(t, got.key.Secret())
|
||||
assert.Len(t, got.cmds, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) {
|
||||
ctx := authz.NewMockContext("inst1", "org1", "user1")
|
||||
|
||||
cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
|
||||
key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg)
|
||||
require.NoError(t, err)
|
||||
userAgg := &user.NewAggregate("user1", "org1").Aggregate
|
||||
|
||||
code, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
type args struct {
|
||||
userID string
|
||||
code string
|
||||
resourceOwner string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{},
|
||||
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing"),
|
||||
},
|
||||
{
|
||||
name: "filter error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilterError(io.ErrClosedPipe),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "otp not existing error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPRemovedEvent(ctx, userAgg),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotExisting"),
|
||||
},
|
||||
{
|
||||
name: "otp already ready error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(context.Background(),
|
||||
userAgg,
|
||||
"agent1",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
resourceOwner: "org1",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-qx4ls", "Errors.Users.MFA.OTP.AlreadyReady"),
|
||||
},
|
||||
{
|
||||
name: "wrong code",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
resourceOwner: "org1",
|
||||
code: "wrong",
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
|
||||
},
|
||||
{
|
||||
name: "push error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
),
|
||||
expectPushFailed(io.ErrClosedPipe,
|
||||
[]*repository.Event{eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(ctx,
|
||||
userAgg,
|
||||
"agent1",
|
||||
),
|
||||
)},
|
||||
),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
resourceOwner: "org1",
|
||||
code: code,
|
||||
userID: "user1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
),
|
||||
expectPush([]*repository.Event{eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(ctx,
|
||||
userAgg,
|
||||
"agent1",
|
||||
),
|
||||
)}),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
resourceOwner: "org1",
|
||||
code: code,
|
||||
userID: "user1",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
multifactors: domain.MultifactorConfigs{
|
||||
OTP: domain.OTPConfig{
|
||||
CryptoMFA: cryptoAlg,
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := c.HumanCheckMFAOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if tt.want {
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "org1", got.ResourceOwner)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSide_RemoveHumanOTP(t *testing.T) {
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
|
@ -26,6 +26,9 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordStrin
|
||||
if !existingPassword.UserState.Exists() {
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0fs", "Errors.User.NotFound")
|
||||
}
|
||||
if err = c.checkPermission(ctx, domain.PermissionUserWrite, existingPassword.ResourceOwner, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password := &domain.Password{
|
||||
SecretString: passwordString,
|
||||
ChangeRequired: oneTime,
|
||||
@ -46,28 +49,28 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordStrin
|
||||
return writeModelToObjectDetails(&existingPassword.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string, passwordVerificationCode crypto.Generator) (err error) {
|
||||
func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string) (objectDetails *domain.ObjectDetails, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
if userID == "" {
|
||||
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing")
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing")
|
||||
}
|
||||
if passwordString == "" {
|
||||
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty")
|
||||
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty")
|
||||
}
|
||||
existingCode, err := c.passwordWriteModel(ctx, userID, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingCode.Code == nil || existingCode.UserState == domain.UserStateUnspecified || existingCode.UserState == domain.UserStateDeleted {
|
||||
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound")
|
||||
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound")
|
||||
}
|
||||
|
||||
err = crypto.VerifyCode(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, passwordVerificationCode)
|
||||
err = crypto.VerifyCodeWithAlgorithm(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, c.userEncryption)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
password := &domain.Password{
|
||||
@ -77,10 +80,13 @@ func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID,
|
||||
userAgg := UserAggregateFromWriteModel(&existingCode.WriteModel)
|
||||
passwordEvent, err := c.changePassword(ctx, userAgentID, password, userAgg, existingCode)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
_, err = c.eventstore.Push(ctx, passwordEvent)
|
||||
return err
|
||||
err = c.pushAppendAndReduce(ctx, existingCode, passwordEvent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return writeModelToObjectDetails(&existingCode.WriteModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPassword, newPassword, userAgentID string) (objectDetails *domain.ObjectDetails, err error) {
|
||||
|
@ -2,6 +2,7 @@ package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -22,6 +23,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
userPasswordAlg crypto.HashAlgorithm
|
||||
checkPermission domain.PermissionCheck
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
@ -72,6 +74,49 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
|
||||
err: caos_errs.IsPreconditionFailed,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing permission, error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanEmailVerifiedEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
checkPermission: newMockPermissionCheckNotAllowed(),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
password: "password",
|
||||
oneTime: true,
|
||||
},
|
||||
res: res{
|
||||
err: func(err error) bool {
|
||||
return errors.Is(err, caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"))
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "change password onetime, ok",
|
||||
fields: fields{
|
||||
@ -129,6 +174,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
|
||||
),
|
||||
),
|
||||
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
checkPermission: newMockPermissionCheckAllowed(),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
@ -200,6 +246,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
|
||||
),
|
||||
),
|
||||
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
checkPermission: newMockPermissionCheckAllowed(),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
@ -220,6 +267,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
|
||||
r := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
userPasswordAlg: tt.fields.userPasswordAlg,
|
||||
checkPermission: tt.fields.checkPermission,
|
||||
}
|
||||
got, err := r.SetPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.oneTime)
|
||||
if tt.res.err == nil {
|
||||
@ -235,19 +283,19 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSide_SetPassword(t *testing.T) {
|
||||
func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) {
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
userEncryption crypto.EncryptionAlgorithm
|
||||
userPasswordAlg crypto.HashAlgorithm
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
userID string
|
||||
code string
|
||||
resourceOwner string
|
||||
password string
|
||||
agentID string
|
||||
secretGenerator crypto.Generator
|
||||
ctx context.Context
|
||||
userID string
|
||||
code string
|
||||
resourceOwner string
|
||||
password string
|
||||
agentID string
|
||||
}
|
||||
type res struct {
|
||||
want *domain.ObjectDetails
|
||||
@ -377,14 +425,14 @@ func TestCommandSide_SetPassword(t *testing.T) {
|
||||
),
|
||||
),
|
||||
),
|
||||
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
userID: "user1",
|
||||
code: "test",
|
||||
resourceOwner: "org1",
|
||||
password: "password",
|
||||
secretGenerator: GetMockSecretGenerator(t),
|
||||
ctx: context.Background(),
|
||||
userID: "user1",
|
||||
code: "test",
|
||||
resourceOwner: "org1",
|
||||
password: "password",
|
||||
},
|
||||
res: res{
|
||||
err: caos_errs.IsPreconditionFailed,
|
||||
@ -460,14 +508,14 @@ func TestCommandSide_SetPassword(t *testing.T) {
|
||||
),
|
||||
),
|
||||
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
password: "password",
|
||||
code: "a",
|
||||
secretGenerator: GetMockSecretGenerator(t),
|
||||
ctx: context.Background(),
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
password: "password",
|
||||
code: "a",
|
||||
},
|
||||
res: res{
|
||||
want: &domain.ObjectDetails{
|
||||
@ -481,14 +529,18 @@ func TestCommandSide_SetPassword(t *testing.T) {
|
||||
r := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
userPasswordAlg: tt.fields.userPasswordAlg,
|
||||
userEncryption: tt.fields.userEncryption,
|
||||
}
|
||||
err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID, tt.args.secretGenerator)
|
||||
got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID)
|
||||
if tt.res.err == nil {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
if tt.res.err != nil && !tt.res.err(err) {
|
||||
t.Errorf("got wrong err: %v ", err)
|
||||
}
|
||||
if tt.res.err == nil {
|
||||
assert.Equal(t, tt.res.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -27,8 +27,8 @@ func (c *Commands) getHumanU2FTokens(ctx context.Context, userID, resourceowner
|
||||
return readModelToU2FTokens(tokenReadModel), nil
|
||||
}
|
||||
|
||||
func (c *Commands) getHumanPasswordlessTokens(ctx context.Context, userID, resourceowner string) ([]*domain.WebAuthNToken, error) {
|
||||
tokenReadModel := NewHumanPasswordlessTokensReadModel(userID, resourceowner)
|
||||
func (c *Commands) getHumanPasswordlessTokens(ctx context.Context, userID, resourceOwner string) ([]*domain.WebAuthNToken, error) {
|
||||
tokenReadModel := NewHumanPasswordlessTokensReadModel(userID, resourceOwner)
|
||||
err := c.eventstore.FilterToQueryReducer(ctx, tokenReadModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -82,12 +82,12 @@ func (c *Commands) HumanAddU2FSetup(ctx context.Context, userID, resourceowner s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addWebAuthN, userAgg, webAuthN, err := c.addHumanWebAuthN(ctx, userID, resourceowner, isLoginUI, u2fTokens, domain.AuthenticatorAttachmentUnspecified, domain.UserVerificationRequirementDiscouraged)
|
||||
addWebAuthN, userAgg, webAuthN, err := c.addHumanWebAuthN(ctx, userID, resourceowner, "", u2fTokens, domain.AuthenticatorAttachmentUnspecified, domain.UserVerificationRequirementDiscouraged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
events, err := c.eventstore.Push(ctx, usr_repo.NewHumanU2FAddedEvent(ctx, userAgg, addWebAuthN.WebauthNTokenID, webAuthN.Challenge))
|
||||
events, err := c.eventstore.Push(ctx, usr_repo.NewHumanU2FAddedEvent(ctx, userAgg, addWebAuthN.WebauthNTokenID, webAuthN.Challenge, ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -108,12 +108,12 @@ func (c *Commands) HumanAddPasswordlessSetup(ctx context.Context, userID, resour
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addWebAuthN, userAgg, webAuthN, err := c.addHumanWebAuthN(ctx, userID, resourceowner, isLoginUI, passwordlessTokens, authenticatorPlatform, domain.UserVerificationRequirementRequired)
|
||||
addWebAuthN, userAgg, webAuthN, err := c.addHumanWebAuthN(ctx, userID, resourceowner, "", passwordlessTokens, authenticatorPlatform, domain.UserVerificationRequirementRequired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
events, err := c.eventstore.Push(ctx, usr_repo.NewHumanPasswordlessAddedEvent(ctx, userAgg, addWebAuthN.WebauthNTokenID, webAuthN.Challenge))
|
||||
events, err := c.eventstore.Push(ctx, usr_repo.NewHumanPasswordlessAddedEvent(ctx, userAgg, addWebAuthN.WebauthNTokenID, webAuthN.Challenge, ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -137,7 +137,7 @@ func (c *Commands) HumanAddPasswordlessSetupInitCode(ctx context.Context, userID
|
||||
return c.HumanAddPasswordlessSetup(ctx, userID, resourceowner, true, preferredPlatformType)
|
||||
}
|
||||
|
||||
func (c *Commands) addHumanWebAuthN(ctx context.Context, userID, resourceowner string, isLoginUI bool, tokens []*domain.WebAuthNToken, authenticatorPlatform domain.AuthenticatorAttachment, userVerification domain.UserVerificationRequirement) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
|
||||
func (c *Commands) addHumanWebAuthN(ctx context.Context, userID, resourceowner, rpID string, tokens []*domain.WebAuthNToken, authenticatorPlatform domain.AuthenticatorAttachment, userVerification domain.UserVerificationRequirement) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
|
||||
if userID == "" {
|
||||
return nil, nil, nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0od", "Errors.IDMissing")
|
||||
}
|
||||
@ -157,7 +157,7 @@ func (c *Commands) addHumanWebAuthN(ctx context.Context, userID, resourceowner s
|
||||
if accountName == "" {
|
||||
accountName = string(user.EmailAddress)
|
||||
}
|
||||
webAuthN, err := c.webauthnConfig.BeginRegistration(ctx, user, accountName, authenticatorPlatform, userVerification, isLoginUI, tokens...)
|
||||
webAuthN, err := c.webauthnConfig.BeginRegistration(ctx, user, accountName, authenticatorPlatform, userVerification, rpID, tokens...)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@ -343,7 +343,7 @@ func (c *Commands) beginWebAuthNLogin(ctx context.Context, userID, resourceOwner
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, human, userVerification, tokens...)
|
||||
webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, human, userVerification, "", tokens...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user