Merge branch 'main' into next

# Conflicts:
#	cmd/setup/11.go
#	console/src/app/utils/language.ts
This commit is contained in:
Livio Spring 2023-06-27 21:31:28 +02:00
commit 5060c7463a
No known key found for this signature in database
GPG Key ID: 26BB1C2FA5952CF0
156 changed files with 12104 additions and 3509 deletions

View File

@ -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
}
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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> {

View File

@ -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 {

View File

@ -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();

View File

@ -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

View File

@ -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>

View File

@ -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 {

View File

@ -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
// */

View File

@ -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) => {

View File

@ -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()">

View File

@ -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);

View File

@ -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;

View File

@ -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 } });
});
}

View File

@ -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);

View File

@ -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);

View File

@ -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) => {

View File

@ -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;

View File

@ -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();

View File

@ -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;

View File

@ -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>

View File

@ -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`);
}
}

View File

@ -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';

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "マネージャーを追加する",

View File

@ -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",

View File

@ -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": "添加管理者",

View 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”.

View 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"
}
}
}
]
}
```

View 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 havent added a provider yet, have a look at the following guide first: [Identity Providers](https://zitadel.com/docs/guides/integrate/identity-providers)
![Identity Provider Flow](/img/guides/login-ui/external-login-flow.png)
## 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'tt 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"
}
}'
```

View File

@ -0,0 +1,7 @@
---
title: Logout
---
import Logout from './_logout.mdx';
<Logout/>

View 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.
![OIDC Flow](/img/guides/login-ui/oidc-flow.png)
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

View File

@ -0,0 +1,7 @@
---
title: Select Account
---
import SelectAccount from './_select-account.mdx';
<SelectAccount/>

View File

@ -0,0 +1,236 @@
---
title: Register and Login User with Password
sidebar_label: Username and Password
---
## Flow
![Register and Login Flow](/img/guides/login-ui/username-password-flow.png)
## 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/>

View File

@ -42,6 +42,7 @@ ZITADEL is available in the following languages
- 日本語 (ja)
- Polishpl
- 简体中文zh
- Bulgarian (bg)
A language is displayed based on your agent's language header. The default language is English.

View File

@ -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.
![](/img/console_projects_application_token_settings.png)
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).

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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 customers 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.

View File

@ -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.

View File

@ -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.

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

6
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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())
}
}
}

View File

@ -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()))
}

View File

@ -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()

View File

@ -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")
}

View File

@ -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{

View File

@ -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,

View 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
}

View 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)
})
}
}

View 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)
})
}
}

View 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
}

View 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)
})
}
}

View 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: &timestamppb.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)
}
})
}
}

View 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
}

View 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)
})
}
}

View 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: &timestamppb.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())
}
})
}
}

View File

@ -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
}
}

View File

@ -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())
})
}
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -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

View 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: (по избор)

View File

@ -222,6 +222,7 @@ RegistrationUser:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
GenderLabel: Geschlecht
Female: weiblich
Male: männlich

View File

@ -316,7 +316,7 @@ ExternalNotFound:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
DeviceAuth:
Title: Device Authorization
UserCode:

View File

@ -222,6 +222,7 @@ RegistrationUser:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
GenderLabel: Género
Female: Mujer
Male: Hombre

View File

@ -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

View File

@ -222,6 +222,7 @@ RegistrationUser:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
GenderLabel: Genere
Female: Femminile
Male: Maschile

View File

@ -214,6 +214,7 @@ RegistrationUser:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
GenderLabel: 性別
Female: 女性
Male: 男性

View File

@ -222,6 +222,7 @@ RegistrationUser:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
GenderLabel: Płeć
Female: Kobieta
Male: Mężczyzna

View File

@ -222,6 +222,7 @@ RegistrationUser:
Polish: Polski
Japanese: 日本語
Spanish: Español
Bulgarian: Български
GenderLabel: 性别
Female: 女性
Male: 男性

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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...)

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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,

View File

@ -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"),

View File

@ -140,6 +140,7 @@ func writeModelToWebAuthN(wm *HumanWebAuthNWriteModel) *domain.WebAuthNToken {
SignCount: wm.SignCount,
WebAuthNTokenName: wm.WebAuthNTokenName,
State: wm.State,
RPID: wm.RPID,
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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) {

View File

@ -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)
}
})
}
}

View File

@ -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