update base

This commit is contained in:
Elio Bischof 2023-06-29 12:45:45 +02:00
commit ad13a65d1d
No known key found for this signature in database
GPG Key ID: 7B383FDE4DDBF1BD
117 changed files with 7254 additions and 3466 deletions

1
.gitignore vendored
View File

@ -59,6 +59,7 @@ docs/docs/apis/auth
docs/docs/apis/admin
docs/docs/apis/mgmt
docs/docs/apis/system
docs/docs/apis/proto
# local
build/local/*.env

View File

@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"embed"
"time"
"github.com/cockroachdb/cockroach-go/v2/crdb"
"github.com/zitadel/logging"
@ -15,10 +14,8 @@ import (
var (
//go:embed 11/11_add_column.sql
addEventCreatedAt string
//go:embed 11/11_fetch_events.sql
fetchCreatedAt string
//go:embed 11/11_fill_column.sql
fillCreatedAt string
//go:embed 11/11_update_events.sql
setCreatedAt string
//go:embed 11/11_set_column.sql
setCreatedAtDetails string
//go:embed 11/postgres/create_index.sql
@ -55,49 +52,22 @@ func (mig *AddEventCreatedAt) Execute(ctx context.Context) error {
return err
}
for {
var count int
for i := 0; ; i++ {
var affected int64
err = crdb.ExecuteTx(ctx, mig.dbClient.DB, nil, func(tx *sql.Tx) error {
rows, err := tx.Query(fetchCreatedAt, mig.BulkAmount)
res, err := tx.Exec(setCreatedAt, mig.BulkAmount)
if err != nil {
return err
}
defer rows.Close()
data := make(map[string]time.Time, 20)
for rows.Next() {
count++
var (
id string
creationDate time.Time
)
err = rows.Scan(&id, &creationDate)
if err != nil {
return err
}
data[id] = creationDate
}
if err := rows.Err(); err != nil {
return err
}
for id, creationDate := range data {
_, err = tx.Exec(fillCreatedAt, creationDate, id)
if err != nil {
return err
}
}
affected, _ = res.RowsAffected()
return nil
})
if err != nil {
return err
}
logging.WithFields("count", count).Info("creation dates set")
if count < 20 {
logging.WithFields("step", "11", "iteration", i, "affected", affected).Info("set created_at iteration done")
if affected < int64(mig.BulkAmount) {
break
}
}

View File

@ -1,12 +0,0 @@
SELECT
id
, creation_date
FROM
eventstore.events
WHERE
created_at IS NULL
ORDER BY
event_sequence DESC
, instance_id
LIMIT $1
FOR UPDATE

View File

@ -1 +0,0 @@
UPDATE eventstore.events SET created_at = $1 WHERE id = $2

View File

@ -0,0 +1,21 @@
UPDATE eventstore.events SET
created_at = creation_date
FROM (
SELECT
e.event_sequence as seq
, e.instance_id as i_id
, e.creation_date as cd
FROM
eventstore.events e
WHERE
created_at IS NULL
ORDER BY
event_sequence ASC
, instance_id
LIMIT $1
) AS e
WHERE
e.seq = eventstore.events.event_sequence
AND e.i_id = eventstore.events.instance_id
AND e.cd = eventstore.events.creation_date
;

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

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

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

@ -18,14 +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 = 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

@ -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,106 @@
---
title: Password Reset/Change
---
When your user is on the password screen and has forgotten his password you will probably want him to be able to reset by himself.
## Flow
![Register and Login Flow](/img/guides/login-ui/password-reset-flow.png)
## Request Password Reset
First you will have to make a request, to ask for a password reset.
The goal is to send the user a verification code, which he can use to verify the password reset request.
There are two possible ways: You can either let ZITADEL send the notification with the verification code, or you can ask ZITADEL for returning the code and send the email by yourself.
### ZITADEL sends the verification message
When you want ZITADEL to send the verification code you can define the notification channel.
Per default the verification code will be sent to the email address of the user.
Make sure to also include the URL Template to customize the reset link in the email sent to the user.
### Request
```bash
curl --request POST \
--url https://$ZITADEL_DOMAIN/v2alpha/users/$USER_ID/password_reset \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '"$TOKEN"'' \
--header 'Content-Type: application/json' \
--data '{
"sendLink": {
"notificationType": "NOTIFICATION_TYPE_Email",
"urlTemplate": "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"
}
}'
```
### ZITADEL returns the code
Send the request with asking for the return Code in the body of the request.
#### Request
```bash
curl --request POST \
--url https://$ZITADEL_DOMAIN/v2alpha/users/$USER_ID/password_reset \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '"$TOKEN"'' \
--header 'Content-Type: application/json' \
--data '{
"returnCode": {}
}'
```
#### Response
You will get the verification code in the response:
```bash
{
"details": {
"sequence": "625",
"changeDate": "2023-06-27T15:02:10.321773Z",
"resourceOwner": "163840776835432705"
},
"verificationCode": "IBJMUC"
}
```
## Send Verification Code
The verification code is generated and ZITADEL has sent it with the defined channel (email or sms) to your users.
If you have chosen to get the code back in the response, you should now send the code to your user.
## Change Password
The next screen should allow the user to enter the verification code and a new password.
From a user experience perspective it is nice to prefill the verification code, so the user doesn't have to do manually.
As soon as the user has typed the new password, you can send the change password request.
The change password request allows you to set a new password for the user.
:::note
This request can be used in the password reset flow as well as to let your user change the password manually.
In this case it requires additionally the current password instead of the verification code.
:::
### Request
```bash
curl --request POST \
--url https://$ZITADEL_DOMAIN/v2alpha/users/$USER_ID/password \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '"$TOKEN"'' \
--header 'Content-Type: application/json' \
--data '{
"newPassword": {
"password": "Secr3tP4ssw0rd!",
"changeRequired": false
},
"verificationCode": "48CDAP"
}'
```

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](/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

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

@ -0,0 +1,65 @@
---
title: Frontend and API communication
---
This guide contains a use case and ZITADEL integration.
In a typical web application architecture, the front-end and back-end communicate to exchange data and provide functionality to users. Let's consider a use case where a front-end application needs to communicate with a back-end API using secure authentication and authorization. Lets explore how ZITADEL can be used to add front-end login and facilitate this communication.
Single-Page Applications (SPAs) are web applications that run entirely in the browser, without a back-end server. In ZITADEL SPAs should use the Authorization Code Grant with PKCE or the Implicit Grant (if PKCE is not feasible) to obtain an access token.
While APIs are vital for communication between applications and services, they don't directly participate in user authentication. Instead, they often authorize client requests based on access tokens issued by an authorization server. APIs in ZITADEL use grant types like JWT Profile or Basic Authentication to access the authorization server's introspection endpoint for token validation.
## A real-world scenario
Suppose there is a news portal web app that allows users to browse through various news articles and personalize their news feed based on their preferences. The back-end API handles fetching and delivering news content from the database.
**Front-End Login**: A user visits the news portal and opts to log in for a personalized news feed. They are redirected to the Identity Providers (IdP) login page, where they authenticate with their credentials. Upon successful authentication, the IdP issues access and ID tokens to the front-end app.
** Back-End API Communication**: When the user browses through the news feed, the front-end app makes an API request to the back-end, including the access token in the Authorization header. The back-end API, upon receiving the request, uses the IdPs introspection endpoint to validate the access token. Once validated, it fetches personalized news data based on the user's preferences from the news database.
While it is true that the back-end API typically needs to authenticate with the IdP, in this specific use case, the back-end API can work with a client credential / JWT since it's not a public client. This means that instead of relying on user-specific authentication, the back-end API can obtain a client credential (such as a client ID and client secret) from the IdP to authenticate itself and validate the access token received from the front-end app. This approach ensures secure communication between the front-end app, the back-end API, and the IdP, while still allowing the back-end API to access user-specific data and provide personalized news feeds.
## A simplified example with a React frontend and a Python Flask API
In this example, the application is a web-based quote generator that employs a secure user authentication system via ZITADEL. The functionality of the app is outlined below:
1. Upon starting, the application provides a login button
2. The user is then redirected to ZITADEL's login page to enter their credentials.
3. Once the login is successful, the application then greets the user by extracting the user's name, thus providing a personalized experience.
4. The application presents an option for the user to generate a quote via a button. Upon pressing this button, the front-end application communicates with the back-end API using the user's access token received from ZITADEL.
5. The back-end API introspects the access token for validity using ZITADEL's introspection endpoint. If the token is valid, the API generates a quote and sends it as a response to the front-end application, which is then displayed to the user in their browser.
## Setting up the applications and ZITADEL
All code and instructions to run the sample application can be found at [https://github.com/zitadel/example-quote-generator-app/](https://github.com/zitadel/example-quote-generator-app/). You can also find the steps for the integration between the front-end, back-end API, and ZITADEL in the README.md.
You can create the front-end application (User Agent) and the API in the same project or in a different project. In this example, we have created both in one. Configure the applications with appropriate settings (as instructed).
<img src="/docs/img/guides/solution-scenarios/frontend-calling-backend-API_1.png" alt="User Agent and API applications in a single project"/>
### Front-end login with ZITADEL
- You must create a User Agent application in your project to add login to your React application using the Authorization Code with PKCE flow. This allows the front-end application to integrate with ZITADEL to enable user authentication and authorization.
- In the React front-end application, configure the ZITADEL OIDC client settings, including the client ID, ZITADEL URLs, redirect URIs, and required scopes.
- Implement the login flow, authentication callbacks, and token handling logic in the front-end application.
- When a user visits the front-end application, they are presented with a login option.
- Upon clicking the login button, the frontend initiates the Authorization Code with PKCE authentication flow and redirects the user to the ZITADEL login page. The user enters their credentials and authenticates with ZITADEL. Authorization Code Flow returns an authorization code to the client application, which can then exchange it for an ID token and an access token directly. This provides the benefit of not exposing any tokens to the user agent and possibly other malicious applications with access to the user agent.
- You must set up the required scopes and claims to ensure the front-end and back-end can exchange data securely. Its important to note that when specifying the scope when calling the token API, the scope must contain the project ID of the ZITADEL project in which the API resides (to enable token validation by the back-end API):`scope:'openid profile email urn:zitadel:iam:org:project:id:<API_PROJECT_ID>:aud'`
- Also, we want to include user info inside the token to avoid calling the user info endpoint, so go to Token Settings in the front-end app and select User Info inside ID Token.
- After a successful authentication, ZITADEL generates an access token and an ID token.
- The front-end application receives these tokens and stores them securely (e.g., in browser storage).
### Token exchange and user information
- Once the frontend obtains the tokens, it can extract certain information from the ID token itself (e.g., user ID, email, etc.) without making an additional request.
- If more user information is required, the frontend can use the access token to call the ZITADEL User Info endpoint. This endpoint provides additional user details, such as name, profile picture, etc.
### Back-end API communication
- To communicate with the back-end API, the front-end includes the access token in the Authorization header of API requests.
- The back-end API receives the request and needs to validate and authorize the token before processing the request.
- The API performs token introspection using ZITADEL's introspection endpoint to validate the access token. This API uses Basic Authentication to invoke the [introspection endpoint](/docs/apis/openidoauth/endpoints#introspection_endpoint), which means it sends its client ID and client secret along with the access token received.
- If the token is valid and active, the API proceeds to handle the requested action or fetch data from the underlying data sources.

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

@ -167,6 +167,27 @@ 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/password-reset",
"guides/integrate/login-ui/logout",
"guides/integrate/login-ui/oidc-standard"
],
},
{
type: "category",
label: "Configure identity providers",
@ -273,6 +294,7 @@ module.exports = {
"guides/solution-scenarios/saas",
"guides/solution-scenarios/domain-discovery",
"guides/solution-scenarios/configurations",
"guides/solution-scenarios/frontend-calling-backend-API",
],
},
{

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: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

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,19 +10,21 @@ 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) {
@ -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

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

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

@ -12,7 +12,7 @@ import (
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),
s.command.RegisterUserU2F(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner, req.GetDomain()),
)
}

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

@ -292,6 +292,13 @@ func (l *Login) handleExternalUserAuthenticated(
l.renderError(w, r, authReq, externalErr)
return
}
var err error
// read current auth request state (incl. authorized user)
authReq, err = l.authRepo.AuthRequestByID(r.Context(), authReq.ID, authReq.AgentID)
if err != nil {
l.renderError(w, r, authReq, err)
return
}
externalUser, externalUserChange, err := l.runPostExternalAuthenticationActions(externalUser, tokens(session), authReq, r, user, nil)
if err != nil {
l.renderError(w, r, authReq, err)
@ -302,14 +309,6 @@ func (l *Login) handleExternalUserAuthenticated(
l.externalUserNotExisting(w, r, authReq, provider, externalUser, externalUserChange)
return
}
if provider.IsAutoUpdate || len(externalUser.Metadatas) > 0 || externalUserChange {
// read current auth request state (incl. authorized user)
authReq, err = l.authRepo.AuthRequestByID(r.Context(), authReq.ID, authReq.AgentID)
if err != nil {
l.renderError(w, r, authReq, err)
return
}
}
if provider.IsAutoUpdate || externalUserChange {
err = l.updateExternalUser(r.Context(), authReq, externalUser)
if err != 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

@ -247,6 +247,8 @@ func (repo *AuthRequestRepo) CheckExternalUserLogin(ctx context.Context, authReq
}
err = repo.checkExternalUserLogin(ctx, request, externalUser.IDPConfigID, externalUser.ExternalUserID)
if errors.IsNotFound(err) {
// clear potential user information (e.g. when username was entered but another external user was returned)
request.SetUserInfo("", "", "", "", "", request.UserOrgID)
if err := repo.setLinkingUser(ctx, request, externalUser); err != nil {
return err
}

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
}

View File

@ -21,6 +21,7 @@ type HumanWebAuthNWriteModel struct {
AAGUID []byte
SignCount uint32
WebAuthNTokenName string
RPID string
State domain.MFAState
}
@ -113,6 +114,7 @@ func (wm *HumanWebAuthNWriteModel) Reduce() error {
func (wm *HumanWebAuthNWriteModel) appendAddedEvent(e *user.HumanWebAuthNAddedEvent) {
wm.WebauthNTokenID = e.WebAuthNTokenID
wm.Challenge = e.Challenge
wm.RPID = e.RPID
wm.State = domain.MFAStateNotReady
}

View File

@ -31,17 +31,17 @@ func (wm *UserIDPLinkWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *user.UserIDPLinkAddedEvent:
if e.IDPConfigID != wm.IDPConfigID && e.ExternalUserID != wm.ExternalUserID {
if e.IDPConfigID != wm.IDPConfigID || e.ExternalUserID != wm.ExternalUserID {
continue
}
wm.WriteModel.AppendEvents(e)
case *user.UserIDPLinkRemovedEvent:
if e.IDPConfigID != wm.IDPConfigID && e.ExternalUserID != wm.ExternalUserID {
if e.IDPConfigID != wm.IDPConfigID || e.ExternalUserID != wm.ExternalUserID {
continue
}
wm.WriteModel.AppendEvents(e)
case *user.UserIDPLinkCascadeRemovedEvent:
if e.IDPConfigID != wm.IDPConfigID && e.ExternalUserID != wm.ExternalUserID {
if e.IDPConfigID != wm.IDPConfigID || e.ExternalUserID != wm.ExternalUserID {
continue
}
wm.WriteModel.AppendEvents(e)

View File

@ -15,23 +15,23 @@ import (
)
// RegisterUserPasskey creates a passkey registration for the current authenticated user.
// UserID, ussualy taken from the request is compaired against the user ID in the context.
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*domain.WebAuthNRegistrationDetails, error) {
// UserID, usually taken from the request is compared against the user ID in the context.
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner, rpID string, authenticator domain.AuthenticatorAttachment) (*domain.WebAuthNRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
return c.registerUserPasskey(ctx, userID, resourceOwner, authenticator)
return c.registerUserPasskey(ctx, userID, resourceOwner, rpID, authenticator)
}
// RegisterUserPasskeyWithCode registers a new passkey for a unauthenticated user id.
// The resource is protected by the code, identified by the codeID.
func (c *Commands) RegisterUserPasskeyWithCode(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, codeID, code string, alg crypto.EncryptionAlgorithm) (*domain.WebAuthNRegistrationDetails, error) {
func (c *Commands) RegisterUserPasskeyWithCode(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, codeID, code, rpID string, alg crypto.EncryptionAlgorithm) (*domain.WebAuthNRegistrationDetails, error) {
event, err := c.verifyUserPasskeyCode(ctx, userID, resourceOwner, codeID, code, alg)
if err != nil {
return nil, err
}
return c.registerUserPasskey(ctx, userID, resourceOwner, authenticator, event)
return c.registerUserPasskey(ctx, userID, resourceOwner, rpID, authenticator, event)
}
type eventCallback func(context.Context, *eventstore.Aggregate) eventstore.Command
@ -40,7 +40,7 @@ type eventCallback func(context.Context, *eventstore.Aggregate) eventstore.Comma
// A code can only be used once.
// Upon success an event callback is returned, which must be called after
// all other events for the current request are created.
// This prevent consuming a code when another error occurred after verification.
// This prevents consuming a code when another error occurred after verification.
func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOwner, codeID, code string, alg crypto.EncryptionAlgorithm) (eventCallback, error) {
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, wm)
@ -63,25 +63,25 @@ func (c *Commands) verifyUserPasskeyCodeFailed(ctx context.Context, wm *HumanPas
logging.WithFields("userID", userAgg.ID).OnError(err).Error("RegisterUserPasskeyWithCode push failed")
}
func (c *Commands) registerUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, events ...eventCallback) (*domain.WebAuthNRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, userID, resourceOwner, authenticator)
func (c *Commands) registerUserPasskey(ctx context.Context, userID, resourceOwner, rpID string, authenticator domain.AuthenticatorAttachment, events ...eventCallback) (*domain.WebAuthNRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, userID, resourceOwner, rpID, authenticator)
if err != nil {
return nil, err
}
return c.pushUserPasskey(ctx, wm, userAgg, webAuthN, events...)
}
func (c *Commands) createUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
func (c *Commands) createUserPasskey(ctx context.Context, userID, resourceOwner, rpID string, authenticator domain.AuthenticatorAttachment) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
passwordlessTokens, err := c.getHumanPasswordlessTokens(ctx, userID, resourceOwner)
if err != nil {
return nil, nil, nil, err
}
return c.addHumanWebAuthN(ctx, userID, resourceOwner, false, passwordlessTokens, authenticator, domain.UserVerificationRequirementRequired)
return c.addHumanWebAuthN(ctx, userID, resourceOwner, rpID, passwordlessTokens, authenticator, domain.UserVerificationRequirementRequired)
}
func (c *Commands) pushUserPasskey(ctx context.Context, wm *HumanWebAuthNWriteModel, userAgg *eventstore.Aggregate, webAuthN *domain.WebAuthNToken, events ...eventCallback) (*domain.WebAuthNRegistrationDetails, error) {
cmds := make([]eventstore.Command, len(events)+1)
cmds[0] = user.NewHumanPasswordlessAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge)
cmds[0] = user.NewHumanPasswordlessAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge, webAuthN.RPID)
for i, event := range events {
cmds[i+1] = event(ctx, userAgg)
}
@ -122,7 +122,7 @@ func (c *Commands) AddUserPasskeyCodeURLTemplate(ctx context.Context, userID, re
}
// AddUserPasskeyCodeReturn generates and returns a Passkey code.
// No email will be send to the user.
// No email will be sent to the user.
func (c *Commands) AddUserPasskeyCodeReturn(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyCodeDetails, error) {
return c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", true)
}

View File

@ -40,6 +40,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) {
type args struct {
userID string
resourceOwner string
rpID string
authenticator domain.AuthenticatorAttachment
}
tests := []struct {
@ -121,7 +122,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) {
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
}
_, err := c.RegisterUserPasskey(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authenticator)
_, err := c.RegisterUserPasskey(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.rpID, tt.args.authenticator)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
@ -148,6 +149,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) {
type args struct {
userID string
resourceOwner string
rpID string
authenticator domain.AuthenticatorAttachment
codeID string
code string
@ -222,7 +224,7 @@ func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) {
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
}
_, err := c.RegisterUserPasskeyWithCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authenticator, tt.args.codeID, tt.args.code, alg)
_, err := c.RegisterUserPasskeyWithCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authenticator, tt.args.codeID, tt.args.code, tt.args.rpID, alg)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
@ -376,7 +378,7 @@ func TestCommands_pushUserPasskey(t *testing.T) {
expectFilter(eventFromEventPusher(
user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush(
ctx, &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType,
), "111", "challenge"),
), "111", "challenge", "rpID"),
)),
}
@ -394,7 +396,7 @@ func TestCommands_pushUserPasskey(t *testing.T) {
expectPush: func(challenge string) expect {
return expectPushFailed(io.ErrClosedPipe, []*repository.Event{eventFromEventPusher(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge,
userAgg, "123", challenge, "rpID",
),
)})
},
@ -406,7 +408,7 @@ func TestCommands_pushUserPasskey(t *testing.T) {
expectPush: func(challenge string) expect {
return expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge,
userAgg, "123", challenge, "rpID",
),
)})
},
@ -418,7 +420,7 @@ func TestCommands_pushUserPasskey(t *testing.T) {
return expectPush([]*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge,
userAgg, "123", challenge, "rpID",
),
),
eventFromEventPusher(
@ -440,7 +442,7 @@ func TestCommands_pushUserPasskey(t *testing.T) {
webauthnConfig: webauthnConfig,
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
}
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", domain.AuthenticatorAttachmentCrossPlattform)
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", "rpID", domain.AuthenticatorAttachmentCrossPlattform)
require.NoError(t, err)
c.eventstore = eventstoreExpect(t, tt.expectPush(webAuthN.Challenge))

View File

@ -0,0 +1,71 @@
package command
import (
"context"
"io"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/repository/user"
)
// RequestPasswordReset generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) RequestPasswordReset(ctx context.Context, userID string) (*domain.ObjectDetails, *string, error) {
return c.requestPasswordReset(ctx, userID, false, "", domain.NotificationTypeEmail)
}
// RequestPasswordResetURLTemplate generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) RequestPasswordResetURLTemplate(ctx context.Context, userID, urlTmpl string, notificationType domain.NotificationType) (*domain.ObjectDetails, *string, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, nil, err
}
return c.requestPasswordReset(ctx, userID, false, urlTmpl, notificationType)
}
// RequestPasswordResetReturnCode generates a code and does not send a notification email.
// The generated plain text code will be returned.
func (c *Commands) RequestPasswordResetReturnCode(ctx context.Context, userID string) (*domain.ObjectDetails, *string, error) {
return c.requestPasswordReset(ctx, userID, true, "", 0)
}
// requestPasswordReset creates a code for a password change.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be sent to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) requestPasswordReset(ctx context.Context, userID string, returnCode bool, urlTmpl string, notificationType domain.NotificationType) (_ *domain.ObjectDetails, plainCode *string, err error) {
if userID == "" {
return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing")
}
model, err := c.getHumanWriteModelByID(ctx, userID, "")
if err != nil {
return nil, nil, err
}
if !model.UserState.Exists() {
return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound")
}
if model.UserState == domain.UserStateInitial {
return nil, nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised")
}
if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, model.ResourceOwner, userID); err != nil {
return nil, nil, err
}
}
code, err := c.newCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption)
if err != nil {
return nil, nil, err
}
cmd := user.NewHumanPasswordCodeAddedEventV2(ctx, UserAggregateFromWriteModel(&model.WriteModel), code.Crypted, code.Expiry, notificationType, urlTmpl, returnCode)
if returnCode {
plainCode = &code.Plain
}
if err = c.pushAppendAndReduce(ctx, model, cmd); err != nil {
return nil, nil, err
}
return writeModelToObjectDetails(&model.WriteModel), plainCode, nil
}

View File

@ -0,0 +1,611 @@
package command
import (
"context"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"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/repository/user"
)
func TestCommands_RequestPasswordReset(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordReset(tt.args.ctx, tt.args.userID)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_RequestPasswordResetReturnCode(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordResetReturnCode(tt.args.ctx, tt.args.userID)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_RequestPasswordResetURLTemplate(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
urlTmpl string
notificationType domain.NotificationType
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "invalid template",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
userID: "user1",
urlTmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordResetURLTemplate(tt.args.ctx, tt.args.userID, tt.args.urlTmpl, tt.args.notificationType)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_requestPasswordReset(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
newCode cryptoCodeFunc
}
type args struct {
ctx context.Context
userID string
returnCode bool
urlTmpl string
notificationType domain.NotificationType
}
type res struct {
details *domain.ObjectDetails
code *string
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
},
{
name: "code generated",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated template",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
urlTmpl: "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated template sms",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeSms,
"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
urlTmpl: "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
notificationType: domain.NotificationTypeSms,
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated returned",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"",
true,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
returnCode: true,
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: gu.Ptr("code"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
newCode: tt.fields.newCode,
}
got, gotPlainCode, err := c.requestPasswordReset(tt.args.ctx, tt.args.userID, tt.args.returnCode, tt.args.urlTmpl, tt.args.notificationType)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.details, got)
assert.Equal(t, tt.res.code, gotPlainCode)
})
}
}

View File

@ -0,0 +1,33 @@
package command
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
)
func (c *Commands) AddUserTOTP(ctx context.Context, userID, resourceowner string) (*domain.TOTP, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
prep, err := c.createHumanTOTP(ctx, userID, resourceowner)
if err != nil {
return nil, err
}
if err = c.pushAppendAndReduce(ctx, prep.wm, prep.cmds...); err != nil {
return nil, err
}
return &domain.TOTP{
ObjectDetails: writeModelToObjectDetails(&prep.wm.WriteModel),
Secret: prep.key.Secret(),
URI: prep.key.URL(),
}, nil
}
func (c *Commands) CheckUserTOTP(ctx context.Context, userID, code, resourceOwner string) (*domain.ObjectDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
return c.HumanCheckMFAOTPSetup(ctx, userID, code, "", resourceOwner)
}

View File

@ -0,0 +1,263 @@
package command
import (
"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"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestCommands_AddUserTOTP(t *testing.T) {
ctx := authz.NewMockContext("inst1", "org1", "user1")
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
userID string
resourceowner string
}
tests := []struct {
name string
fields fields
args args
want bool
wantErr error
}{
{
name: "wrong user",
args: args{
userID: "foo",
resourceowner: "org1",
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "create otp error",
args: args{
userID: "user1",
resourceowner: "org1",
},
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
),
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-MM9fs", "Errors.User.NotFound"),
},
{
name: "push error",
args: args{
userID: "user1",
resourceowner: "org1",
},
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(ctx,
userAgg,
"org",
),
),
),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(ctx,
userAgg,
true,
true,
true,
),
),
),
expectFilter(),
expectRandomPushFailed(io.ErrClosedPipe, []*repository.Event{eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, nil),
)}),
),
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
args: args{
userID: "user1",
resourceowner: "org1",
},
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
),
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(ctx,
userAgg,
"org",
),
),
),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(ctx,
userAgg,
true,
true,
true,
),
),
),
expectFilter(),
expectRandomPush([]*repository.Event{eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, nil),
)}),
),
},
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{
Issuer: "zitadel.com",
CryptoMFA: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
},
}
got, err := c.AddUserTOTP(ctx, tt.args.userID, tt.args.resourceowner)
require.ErrorIs(t, err, tt.wantErr)
if tt.want {
require.NotNil(t, got)
assert.Equal(t, "org1", got.ResourceOwner)
assert.NotEmpty(t, got.Secret)
assert.NotEmpty(t, got.URI)
}
})
}
}
func TestCommands_CheckUserTOTP(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: "wrong user id",
args: args{
userID: "foo",
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "success",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
),
),
expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanOTPVerifiedEvent(ctx, userAgg, ""),
)}),
),
},
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.CheckUserTOTP(ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner)
require.ErrorIs(t, err, tt.wantErr)
if tt.want {
require.NotNil(t, got)
assert.Equal(t, "org1", got.ResourceOwner)
}
})
}
}

View File

@ -9,31 +9,31 @@ import (
"github.com/zitadel/zitadel/internal/repository/user"
)
func (c *Commands) RegisterUserU2F(ctx context.Context, userID, resourceOwner string) (*domain.WebAuthNRegistrationDetails, error) {
func (c *Commands) RegisterUserU2F(ctx context.Context, userID, resourceOwner, rpID string) (*domain.WebAuthNRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
return c.registerUserU2F(ctx, userID, resourceOwner)
return c.registerUserU2F(ctx, userID, resourceOwner, rpID)
}
func (c *Commands) registerUserU2F(ctx context.Context, userID, resourceOwner string) (*domain.WebAuthNRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserU2F(ctx, userID, resourceOwner)
func (c *Commands) registerUserU2F(ctx context.Context, userID, resourceOwner, rpID string) (*domain.WebAuthNRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserU2F(ctx, userID, resourceOwner, rpID)
if err != nil {
return nil, err
}
return c.pushUserU2F(ctx, wm, userAgg, webAuthN)
}
func (c *Commands) createUserU2F(ctx context.Context, userID, resourceOwner string) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
func (c *Commands) createUserU2F(ctx context.Context, userID, resourceOwner, rpID string) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
tokens, err := c.getHumanU2FTokens(ctx, userID, resourceOwner)
if err != nil {
return nil, nil, nil, err
}
return c.addHumanWebAuthN(ctx, userID, resourceOwner, false, tokens, domain.AuthenticatorAttachmentUnspecified, domain.UserVerificationRequirementRequired)
return c.addHumanWebAuthN(ctx, userID, resourceOwner, rpID, tokens, domain.AuthenticatorAttachmentUnspecified, domain.UserVerificationRequirementRequired)
}
func (c *Commands) pushUserU2F(ctx context.Context, wm *HumanWebAuthNWriteModel, userAgg *eventstore.Aggregate, webAuthN *domain.WebAuthNToken) (*domain.WebAuthNRegistrationDetails, error) {
cmd := user.NewHumanU2FAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge)
cmd := user.NewHumanU2FAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge, webAuthN.RPID)
err := c.pushAppendAndReduce(ctx, wm, cmd)
if err != nil {
return nil, err

View File

@ -37,6 +37,7 @@ func TestCommands_RegisterUserU2F(t *testing.T) {
type args struct {
userID string
resourceOwner string
rpID string
}
tests := []struct {
name string
@ -114,7 +115,7 @@ func TestCommands_RegisterUserU2F(t *testing.T) {
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
}
_, err := c.RegisterUserU2F(ctx, tt.args.userID, tt.args.resourceOwner)
_, err := c.RegisterUserU2F(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.rpID)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
@ -160,7 +161,7 @@ func TestCommands_pushUserU2F(t *testing.T) {
expectFilter(eventFromEventPusher(
user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush(
ctx, &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType,
), "111", "challenge"),
), "111", "challenge", "rpID"),
)),
}
@ -174,7 +175,7 @@ func TestCommands_pushUserU2F(t *testing.T) {
expectPush: func(challenge string) expect {
return expectPushFailed(io.ErrClosedPipe, []*repository.Event{eventFromEventPusher(
user.NewHumanU2FAddedEvent(ctx,
userAgg, "123", challenge,
userAgg, "123", challenge, "rpID",
),
)})
},
@ -185,7 +186,7 @@ func TestCommands_pushUserU2F(t *testing.T) {
expectPush: func(challenge string) expect {
return expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanU2FAddedEvent(ctx,
userAgg, "123", challenge,
userAgg, "123", challenge, "rpID",
),
)})
},
@ -198,7 +199,7 @@ func TestCommands_pushUserU2F(t *testing.T) {
webauthnConfig: webauthnConfig,
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
}
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", domain.AuthenticatorAttachmentCrossPlattform)
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", "rpID", domain.AuthenticatorAttachmentCrossPlattform)
require.NoError(t, err)
c.eventstore = eventstoreExpect(t, tt.expectPush(webAuthN.Challenge))

View File

@ -121,10 +121,14 @@ func IsCodeExpired(creationDate time.Time, expiry time.Duration) bool {
}
func VerifyCode(creationDate time.Time, expiry time.Duration, cryptoCode *CryptoValue, verificationCode string, g Generator) error {
return VerifyCodeWithAlgorithm(creationDate, expiry, cryptoCode, verificationCode, g.Alg())
}
func VerifyCodeWithAlgorithm(creationDate time.Time, expiry time.Duration, cryptoCode *CryptoValue, verificationCode string, algorithm Crypto) error {
if IsCodeExpired(creationDate, expiry) {
return errors.ThrowPreconditionFailed(nil, "CODE-QvUQ4P", "Errors.User.Code.Expired")
}
switch alg := g.Alg().(type) {
switch alg := algorithm.(type) {
case EncryptionAlgorithm:
return verifyEncryptedCode(cryptoCode, verificationCode, alg)
case HashAlgorithm:

View File

@ -71,7 +71,7 @@ func TestVerifyCode(t *testing.T) {
expiry: 5 * time.Minute,
cryptoCode: nil,
verificationCode: "",
g: nil,
g: createMockGenerator(t, createMockCrypto(t)),
},
true,
},

View File

@ -2,6 +2,7 @@ package crypto
import (
"database/sql/driver"
"encoding/base64"
"encoding/json"
"github.com/zitadel/zitadel/internal/errors"
@ -132,3 +133,21 @@ func FillHash(value []byte, alg HashAlgorithm) *CryptoValue {
Crypted: value,
}
}
func CheckToken(alg EncryptionAlgorithm, token string, content string) error {
if token == "" {
return errors.ThrowPermissionDenied(nil, "CRYPTO-Sfefs", "Errors.Intent.InvalidToken")
}
data, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return errors.ThrowPermissionDenied(err, "CRYPTO-Swg31", "Errors.Intent.InvalidToken")
}
decryptedToken, err := alg.DecryptString(data, alg.EncryptionKeyID())
if err != nil {
return errors.ThrowPermissionDenied(err, "CRYPTO-Sf4gt", "Errors.Intent.InvalidToken")
}
if decryptedToken != content {
return errors.ThrowPermissionDenied(nil, "CRYPTO-CRYPTO", "Errors.Intent.InvalidToken")
}
return nil
}

View File

@ -213,7 +213,7 @@ func GetOIDCV1Compliance(appType OIDCApplicationType, grantTypes []OIDCGrantType
checkGrantTypesCombination(compliance, grantTypes)
checkRedirectURIs(compliance, grantTypes, appType, redirectUris)
checkApplicaitonType(compliance, appType, authMethod)
checkApplicationType(compliance, appType, authMethod)
if compliance.NoneCompliant {
compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...)
@ -246,7 +246,7 @@ func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appTy
}
}
func checkApplicaitonType(compliance *Compliance, appType OIDCApplicationType, authMethod OIDCAuthMethodType) {
func checkApplicationType(compliance *Compliance, appType OIDCApplicationType, authMethod OIDCAuthMethodType) {
switch appType {
case OIDCApplicationTypeNative:
GetOIDCV1NativeApplicationCompliance(compliance, authMethod)

View File

@ -17,10 +17,17 @@ type OTP struct {
State MFAState
}
type TOTP struct {
*ObjectDetails
Secret string
URI string
}
func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: accountName})
if err != nil {
return nil, nil, err
return nil, nil, caos_errs.ThrowInternal(err, "TOTP-ieY3o", "Errors.Internal")
}
encryptedSecret, err := crypto.Encrypt([]byte(key.Secret()), cryptoAlg)
if err != nil {

View File

@ -23,6 +23,7 @@ type WebAuthNToken struct {
AAGUID []byte
SignCount uint32
WebAuthNTokenName string
RPID string
}
type WebAuthNLogin struct {
@ -32,6 +33,7 @@ type WebAuthNLogin struct {
Challenge string
AllowedCredentialIDs [][]byte
UserVerification UserVerificationRequirement
RPID string
}
type UserVerificationRequirement int32

View File

@ -28,7 +28,7 @@ type PermissionCheck func(ctx context.Context, permission, orgID, resourceID str
const (
PermissionUserWrite = "user.write"
PermissionSessionRead = "session.read"
PermissionUserRead = "user.read"
PermissionSessionWrite = "session.write"
PermissionSessionDelete = "session.delete"
)

View File

@ -51,6 +51,8 @@ const (
UserAuthMethodTypeOTP
UserAuthMethodTypeU2F
UserAuthMethodTypePasswordless
UserAuthMethodTypePassword
UserAuthMethodTypeIDP
userAuthMethodTypeCount
)

View File

@ -288,14 +288,21 @@ func NewCopyCol(column, from string) handler.Column {
func NewIsNullCond(column string) handler.Condition {
return handler.Condition{
Value: specialWhere(func(param string) (clause string, needsParam bool) {
return fmt.Sprintf("%s IS NULL", column), false
}),
Name: column,
ParameterOpt: func(string) string {
return fmt.Sprintf("%s IS NULL", column)
},
}
}
func NewExpressionCond(expr specialWhere) handler.Condition {
return handler.Condition{Value: expr}
func NewLessThanCond(column string, value interface{}) handler.Condition {
return handler.Condition{
Name: column,
Value: value,
ParameterOpt: func(placeholder string) string {
return " < " + placeholder
},
}
}
// NewCopyStatement creates a new upsert statement which updates a column from an existing row
@ -397,23 +404,17 @@ func columnsToQuery(cols []handler.Column) (names []string, parameters []string,
return names, parameters, values[:parameterIndex]
}
type specialWhere func(param string) (clause string, needsParam bool)
func conditionsToWhere(cols []handler.Condition, paramOffset int) (wheres []string, values []interface{}) {
wheres = make([]string, len(cols))
values = make([]interface{}, 0, len(cols))
for i, col := range cols {
param := strconv.Itoa(i + 1 + paramOffset)
special, ok := col.Value.(specialWhere)
if !ok {
wheres[i] = "(" + col.Name + " = $" + param + ")"
values = append(values, col.Value)
continue
param := "$" + strconv.Itoa(i+1+paramOffset)
wheres[i] = "(" + col.Name + " = " + param + ")"
if col.ParameterOpt != nil {
wheres[i] = "(" + col.Name + col.ParameterOpt(param) + ")"
}
clause, needsValueParam := special(param)
wheres[i] = clause
if needsValueParam {
if col.Value != nil {
values = append(values, col.Value)
}
}

View File

@ -70,3 +70,25 @@ func (m *MockRepository) ExpectPushFailed(err error, expectedEvents []*repositor
)
return m
}
func (m *MockRepository) ExpectRandomPush(expectedEvents []*repository.Event, expectedUniqueConstraints ...*repository.UniqueConstraint) *MockRepository {
m.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, events []*repository.Event, uniqueConstraints ...*repository.UniqueConstraint) error {
assert.Len(m.ctrl.T, events, len(expectedEvents))
assert.Len(m.ctrl.T, expectedUniqueConstraints, len(uniqueConstraints))
return nil
},
)
return m
}
func (m *MockRepository) ExpectRandomPushFailed(err error, expectedEvents []*repository.Event, expectedUniqueConstraints ...*repository.UniqueConstraint) *MockRepository {
m.EXPECT().Push(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, events []*repository.Event, uniqueConstraints ...*repository.UniqueConstraint) error {
assert.Len(m.ctrl.T, events, len(expectedEvents))
assert.Len(m.ctrl.T, expectedUniqueConstraints, len(uniqueConstraints))
return err
},
)
return m
}

View File

@ -3,12 +3,21 @@ package integration
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v2/pkg/oidc"
"golang.org/x/oauth2"
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
openid "github.com/zitadel/zitadel/internal/idp/providers/oidc"
"github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/pkg/grpc/admin"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
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"
@ -17,6 +26,7 @@ import (
type Client struct {
CC *grpc.ClientConn
Admin admin.AdminServiceClient
Mgmt mgmt.ManagementServiceClient
UserV2 user.UserServiceClient
SessionV2 session.SessionServiceClient
}
@ -25,6 +35,7 @@ func newClient(cc *grpc.ClientConn) Client {
return Client{
CC: cc,
Admin: admin.NewAdminServiceClient(cc),
Mgmt: mgmt.NewManagementServiceClient(cc),
UserV2: user.NewUserServiceClient(cc),
SessionV2: session.NewSessionServiceClient(cc),
}
@ -52,6 +63,22 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse
return resp
}
func (s *Tester) CreateUserIDPlink(ctx context.Context, userID, externalID, idpID, username string) *user.AddIDPLinkResponse {
resp, err := s.Client.UserV2.AddIDPLink(
ctx,
&user.AddIDPLinkRequest{
UserId: userID,
IdpLink: &user.IDPLink{
IdpId: idpID,
UserId: externalID,
UserName: username,
},
},
)
logging.OnError(err).Fatal("create human user link")
return resp
}
func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) {
reg, err := s.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
@ -62,6 +89,7 @@ func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) {
pkr, err := s.Client.UserV2.RegisterPasskey(ctx, &user.RegisterPasskeyRequest{
UserId: userID,
Code: reg.GetCode(),
Domain: s.Config.ExternalDomain,
})
logging.OnError(err).Fatal("create user passkey")
attestationResponse, err := s.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
@ -75,3 +103,58 @@ func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) {
})
logging.OnError(err).Fatal("create user passkey")
}
func (s *Tester) AddGenericOAuthProvider(t *testing.T) string {
ctx := authz.WithInstance(context.Background(), s.Instance)
id, _, err := s.Commands.AddOrgGenericOAuthProvider(ctx, s.Organisation.ID, command.GenericOAuthProvider{
Name: "idp",
ClientID: "clientID",
ClientSecret: "clientSecret",
AuthorizationEndpoint: "https://example.com/oauth/v2/authorize",
TokenEndpoint: "https://example.com/oauth/v2/token",
UserEndpoint: "https://api.example.com/user",
Scopes: []string{"openid", "profile", "email"},
IDAttribute: "id",
IDPOptions: idp.Options{
IsLinkingAllowed: true,
IsCreationAllowed: true,
IsAutoCreation: true,
IsAutoUpdate: true,
},
})
require.NoError(t, err)
return id
}
func (s *Tester) CreateIntent(t *testing.T, idpID string) string {
ctx := authz.WithInstance(context.Background(), s.Instance)
id, _, err := s.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", s.Organisation.ID)
require.NoError(t, err)
return id
}
func (s *Tester) CreateSuccessfulIntent(t *testing.T, idpID, userID, idpUserID string) (string, string, time.Time, uint64) {
ctx := authz.WithInstance(context.Background(), s.Instance)
intentID := s.CreateIntent(t, idpID)
writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Organisation.ID)
require.NoError(t, err)
idpUser := openid.NewUser(
&oidc.UserInfo{
Subject: idpUserID,
UserInfoProfile: oidc.UserInfoProfile{
PreferredUsername: "username",
},
},
)
idpSession := &openid.Session{
Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "accessToken",
},
IDToken: "idToken",
},
}
token, err := s.Commands.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, userID)
require.NoError(t, err)
return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence
}

View File

@ -251,6 +251,9 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType)
}
if e.CodeReturned {
return crdb.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType,
@ -317,7 +320,7 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
u.metricFailedDeliveriesSMS,
)
}
err = notify.SendPasswordCode(notifyUser, origin, code)
err = notify.SendPasswordCode(notifyUser, origin, code, e.URLTemplate)
if err != nil {
return nil, err
}

View File

@ -1,13 +1,24 @@
package types
import (
"strings"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordCode(user *query.NotifyUser, origin, code string) error {
url := login.InitPasswordLink(origin, user.ID, code, user.ResourceOwner)
func (notify Notify) SendPasswordCode(user *query.NotifyUser, origin, code, urlTmpl string) error {
var url string
if urlTmpl == "" {
url = login.InitPasswordLink(origin, user.ID, code, user.ResourceOwner)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.PasswordResetMessageType, true)

View File

@ -10,10 +10,11 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
const (
SessionsProjectionTable = "projections.sessions1"
SessionsProjectionTable = "projections.sessions3"
SessionColumnID = "id"
SessionColumnCreationDate = "creation_date"
@ -21,11 +22,13 @@ const (
SessionColumnSequence = "sequence"
SessionColumnState = "state"
SessionColumnResourceOwner = "resource_owner"
SessionColumnDomain = "domain"
SessionColumnInstanceID = "instance_id"
SessionColumnCreator = "creator"
SessionColumnUserID = "user_id"
SessionColumnUserCheckedAt = "user_checked_at"
SessionColumnPasswordCheckedAt = "password_checked_at"
SessionColumnIntentCheckedAt = "intent_checked_at"
SessionColumnPasskeyCheckedAt = "passkey_checked_at"
SessionColumnMetadata = "metadata"
SessionColumnTokenID = "token_id"
@ -47,11 +50,13 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi
crdb.NewColumn(SessionColumnSequence, crdb.ColumnTypeInt64),
crdb.NewColumn(SessionColumnState, crdb.ColumnTypeEnum),
crdb.NewColumn(SessionColumnResourceOwner, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnDomain, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnInstanceID, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnCreator, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()),
crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnIntentCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnPasskeyCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
@ -80,6 +85,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
Event: session.PasswordCheckedType,
Reduce: p.reducePasswordChecked,
},
{
Event: session.IntentCheckedType,
Reduce: p.reduceIntentChecked,
},
{
Event: session.PasskeyCheckedType,
Reduce: p.reducePasskeyChecked,
@ -107,6 +116,15 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
},
},
},
{
Aggregate: user.AggregateType,
EventRedusers: []handler.EventReducer{
{
Event: user.HumanPasswordChangedType,
Reduce: p.reducePasswordChanged,
},
},
},
}
}
@ -124,6 +142,7 @@ func (p *sessionProjection) reduceSessionAdded(event eventstore.Event) (*handler
handler.NewCol(SessionColumnCreationDate, e.CreationDate()),
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnResourceOwner, e.Aggregate().ResourceOwner),
handler.NewCol(SessionColumnDomain, e.Domain),
handler.NewCol(SessionColumnState, domain.SessionStateActive),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnCreator, e.User),
@ -171,6 +190,26 @@ func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*hand
), nil
}
func (p *sessionProjection) reduceIntentChecked(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.IntentCheckedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SDgr2", "reduce.wrong.event.type %s", session.IntentCheckedType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnIntentCheckedAt, e.CheckedAt),
},
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}
func (p *sessionProjection) reducePasskeyChecked(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.PasskeyCheckedEvent)
if !ok {
@ -245,3 +284,21 @@ func (p *sessionProjection) reduceSessionTerminated(event eventstore.Event) (*ha
},
), nil
}
func (p *sessionProjection) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordChangedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Deg3d", "reduce.wrong.event.type %s", user.HumanPasswordChangedType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnPasswordCheckedAt, nil),
},
[]handler.Condition{
handler.NewCond(SessionColumnUserID, e.Aggregate().ID),
crdb.NewLessThanCond(SessionColumnPasswordCheckedAt, e.CreationDate()),
},
), nil
}

View File

@ -11,6 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestSessionProjection_reduces(t *testing.T) {
@ -29,7 +30,9 @@ func TestSessionProjection_reduces(t *testing.T) {
event: getEvent(testEvent(
session.AddedType,
session.AggregateType,
[]byte(`{}`),
[]byte(`{
"domain": "domain"
}`),
), session.AddedEventMapper),
},
reduce: (&sessionProjection{}).reduceSessionAdded,
@ -40,13 +43,14 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.sessions1 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
expectedStmt: "INSERT INTO projections.sessions3 (id, instance_id, creation_date, change_date, resource_owner, domain, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
anyArg{},
anyArg{},
"ro-id",
"domain",
domain.SessionStateActive,
uint64(15),
"editor-user",
@ -76,7 +80,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -109,7 +113,39 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC),
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "instance reduceIntentChecked",
args: args{
event: getEvent(testEvent(
session.AddedType,
session.AggregateType,
[]byte(`{
"checkedAt": "2023-05-04T00:00:00Z"
}`),
), session.IntentCheckedEventMapper),
},
reduce: (&sessionProjection{}).reduceIntentChecked,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -141,7 +177,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -175,7 +211,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedStmt: "UPDATE projections.sessions3 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -207,7 +243,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.sessions1 WHERE (id = $1) AND (instance_id = $2)",
expectedStmt: "DELETE FROM projections.sessions3 WHERE (id = $1) AND (instance_id = $2)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
@ -234,7 +270,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.sessions1 WHERE (instance_id = $1)",
expectedStmt: "DELETE FROM projections.sessions3 WHERE (instance_id = $1)",
expectedArgs: []interface{}{
"agg-id",
},
@ -243,6 +279,39 @@ func TestSessionProjection_reduces(t *testing.T) {
},
},
},
{
name: "reducePasswordChanged",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanPasswordChangedType),
user.AggregateType,
[]byte(`{"secret": {
"cryptoType": 0,
"algorithm": "enc",
"keyID": "id",
"crypted": "cGFzc3dvcmQ="
}}`),
), user.HumanPasswordChangedEventMapper),
},
reduce: (&sessionProjection{}).reducePasswordChanged,
want: wantReduce{
aggregateType: eventstore.AggregateType("user"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions3 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)",
expectedArgs: []interface{}{
nil,
"agg-id",
anyArg{},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -34,6 +34,7 @@ type Queries struct {
idpConfigEncryption crypto.EncryptionAlgorithm
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error)
checkPermission domain.PermissionCheck
DefaultLanguage language.Tag
LoginDir http.FileSystem
@ -55,6 +56,7 @@ func StartQueries(
idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm,
zitadelRoles []authz.RoleMapping,
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error),
permissionCheck func(q *Queries) domain.PermissionCheck,
) (repo *Queries, err error) {
statikLoginFS, err := fs.NewWithNamespace("login")
if err != nil {
@ -95,6 +97,8 @@ func StartQueries(
},
}
repo.checkPermission = permissionCheck(repo)
err = projection.Create(ctx, sqlClient, es, projections, keyEncryptionAlgorithm, certEncryptionAlgorithm)
if err != nil {
return nil, err

View File

@ -29,9 +29,11 @@ type Session struct {
Sequence uint64
State domain.SessionState
ResourceOwner string
Domain string
Creator string
UserFactor SessionUserFactor
PasswordFactor SessionPasswordFactor
IntentFactor SessionIntentFactor
PasskeyFactor SessionPasskeyFactor
Metadata map[string][]byte
}
@ -47,6 +49,10 @@ type SessionPasswordFactor struct {
PasswordCheckedAt time.Time
}
type SessionIntentFactor struct {
IntentCheckedAt time.Time
}
type SessionPasskeyFactor struct {
PasskeyCheckedAt time.Time
}
@ -93,6 +99,10 @@ var (
name: projection.SessionColumnResourceOwner,
table: sessionsTable,
}
SessionColumnDomain = Column{
name: projection.SessionColumnDomain,
table: sessionsTable,
}
SessionColumnInstanceID = Column{
name: projection.SessionColumnInstanceID,
table: sessionsTable,
@ -113,6 +123,10 @@ var (
name: projection.SessionColumnPasswordCheckedAt,
table: sessionsTable,
}
SessionColumnIntentCheckedAt = Column{
name: projection.SessionColumnIntentCheckedAt,
table: sessionsTable,
}
SessionColumnPasskeyCheckedAt = Column{
name: projection.SessionColumnPasskeyCheckedAt,
table: sessionsTable,
@ -202,11 +216,13 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
SessionColumnState.identifier(),
SessionColumnResourceOwner.identifier(),
SessionColumnCreator.identifier(),
SessionColumnDomain.identifier(),
SessionColumnUserID.identifier(),
SessionColumnUserCheckedAt.identifier(),
LoginNameNameCol.identifier(),
HumanDisplayNameCol.identifier(),
SessionColumnPasswordCheckedAt.identifier(),
SessionColumnIntentCheckedAt.identifier(),
SessionColumnPasskeyCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
SessionColumnToken.identifier(),
@ -222,9 +238,11 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
loginName sql.NullString
displayName sql.NullString
passwordCheckedAt sql.NullTime
intentCheckedAt sql.NullTime
passkeyCheckedAt sql.NullTime
metadata database.Map[[]byte]
token sql.NullString
sessionDomain sql.NullString
)
err := row.Scan(
@ -235,11 +253,13 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
&session.State,
&session.ResourceOwner,
&session.Creator,
&sessionDomain,
&userID,
&userCheckedAt,
&loginName,
&displayName,
&passwordCheckedAt,
&intentCheckedAt,
&passkeyCheckedAt,
&metadata,
&token,
@ -252,11 +272,13 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
return nil, "", errors.ThrowInternal(err, "QUERY-SAder", "Errors.Internal")
}
session.Domain = sessionDomain.String
session.UserFactor.UserID = userID.String
session.UserFactor.UserCheckedAt = userCheckedAt.Time
session.UserFactor.LoginName = loginName.String
session.UserFactor.DisplayName = displayName.String
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time
session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time
session.Metadata = metadata
@ -273,11 +295,13 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
SessionColumnState.identifier(),
SessionColumnResourceOwner.identifier(),
SessionColumnCreator.identifier(),
SessionColumnDomain.identifier(),
SessionColumnUserID.identifier(),
SessionColumnUserCheckedAt.identifier(),
LoginNameNameCol.identifier(),
HumanDisplayNameCol.identifier(),
SessionColumnPasswordCheckedAt.identifier(),
SessionColumnIntentCheckedAt.identifier(),
SessionColumnPasskeyCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
countColumn.identifier(),
@ -296,8 +320,10 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
loginName sql.NullString
displayName sql.NullString
passwordCheckedAt sql.NullTime
intentCheckedAt sql.NullTime
passkeyCheckedAt sql.NullTime
metadata database.Map[[]byte]
sessionDomain sql.NullString
)
err := rows.Scan(
@ -308,11 +334,13 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
&session.State,
&session.ResourceOwner,
&session.Creator,
&sessionDomain,
&userID,
&userCheckedAt,
&loginName,
&displayName,
&passwordCheckedAt,
&intentCheckedAt,
&passkeyCheckedAt,
&metadata,
&sessions.Count,
@ -321,11 +349,13 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
if err != nil {
return nil, errors.ThrowInternal(err, "QUERY-SAfeg", "Errors.Internal")
}
session.Domain = sessionDomain.String
session.UserFactor.UserID = userID.String
session.UserFactor.UserCheckedAt = userCheckedAt.Time
session.UserFactor.LoginName = loginName.String
session.UserFactor.DisplayName = displayName.String
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time
session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time
session.Metadata = metadata

View File

@ -17,43 +17,47 @@ import (
)
var (
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` +
` projections.sessions1.creation_date,` +
` projections.sessions1.change_date,` +
` projections.sessions1.sequence,` +
` projections.sessions1.state,` +
` projections.sessions1.resource_owner,` +
` projections.sessions1.creator,` +
` projections.sessions1.user_id,` +
` projections.sessions1.user_checked_at,` +
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions3.id,` +
` projections.sessions3.creation_date,` +
` projections.sessions3.change_date,` +
` projections.sessions3.sequence,` +
` projections.sessions3.state,` +
` projections.sessions3.resource_owner,` +
` projections.sessions3.creator,` +
` projections.sessions3.domain,` +
` projections.sessions3.user_id,` +
` projections.sessions3.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.sessions1.password_checked_at,` +
` projections.sessions1.passkey_checked_at,` +
` projections.sessions1.metadata,` +
` projections.sessions1.token_id` +
` FROM projections.sessions1` +
` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` +
` projections.sessions3.password_checked_at,` +
` projections.sessions3.intent_checked_at,` +
` projections.sessions3.passkey_checked_at,` +
` projections.sessions3.metadata,` +
` projections.sessions3.token_id` +
` FROM projections.sessions3` +
` LEFT JOIN projections.login_names2 ON projections.sessions3.user_id = projections.login_names2.user_id AND projections.sessions3.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions3.user_id = projections.users8_humans.user_id AND projections.sessions3.instance_id = projections.users8_humans.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` +
` projections.sessions1.creation_date,` +
` projections.sessions1.change_date,` +
` projections.sessions1.sequence,` +
` projections.sessions1.state,` +
` projections.sessions1.resource_owner,` +
` projections.sessions1.creator,` +
` projections.sessions1.user_id,` +
` projections.sessions1.user_checked_at,` +
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions3.id,` +
` projections.sessions3.creation_date,` +
` projections.sessions3.change_date,` +
` projections.sessions3.sequence,` +
` projections.sessions3.state,` +
` projections.sessions3.resource_owner,` +
` projections.sessions3.creator,` +
` projections.sessions3.domain,` +
` projections.sessions3.user_id,` +
` projections.sessions3.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.sessions1.password_checked_at,` +
` projections.sessions1.passkey_checked_at,` +
` projections.sessions1.metadata,` +
` projections.sessions3.password_checked_at,` +
` projections.sessions3.intent_checked_at,` +
` projections.sessions3.passkey_checked_at,` +
` projections.sessions3.metadata,` +
` COUNT(*) OVER ()` +
` FROM projections.sessions1` +
` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` +
` FROM projections.sessions3` +
` LEFT JOIN projections.login_names2 ON projections.sessions3.user_id = projections.login_names2.user_id AND projections.sessions3.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions3.user_id = projections.users8_humans.user_id AND projections.sessions3.instance_id = projections.users8_humans.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
sessionCols = []string{
@ -64,11 +68,13 @@ var (
"state",
"resource_owner",
"creator",
"domain",
"user_id",
"user_checked_at",
"login_name",
"display_name",
"password_checked_at",
"intent_checked_at",
"passkey_checked_at",
"metadata",
"token",
@ -82,11 +88,13 @@ var (
"state",
"resource_owner",
"creator",
"domain",
"user_id",
"user_checked_at",
"login_name",
"display_name",
"password_checked_at",
"intent_checked_at",
"passkey_checked_at",
"metadata",
"count",
@ -132,12 +140,14 @@ func Test_SessionsPrepare(t *testing.T) {
domain.SessionStateActive,
"ro",
"creator",
"domain",
"user-id",
testNow,
"login-name",
"display-name",
testNow,
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
@ -156,6 +166,7 @@ func Test_SessionsPrepare(t *testing.T) {
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator",
Domain: "domain",
UserFactor: SessionUserFactor{
UserID: "user-id",
UserCheckedAt: testNow,
@ -165,6 +176,9 @@ func Test_SessionsPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
IntentFactor: SessionIntentFactor{
IntentCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
@ -191,12 +205,14 @@ func Test_SessionsPrepare(t *testing.T) {
domain.SessionStateActive,
"ro",
"creator",
"domain",
"user-id",
testNow,
"login-name",
"display-name",
testNow,
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
{
@ -207,12 +223,14 @@ func Test_SessionsPrepare(t *testing.T) {
domain.SessionStateActive,
"ro",
"creator2",
"domain",
"user-id2",
testNow,
"login-name2",
"display-name2",
testNow,
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
@ -231,6 +249,7 @@ func Test_SessionsPrepare(t *testing.T) {
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator",
Domain: "domain",
UserFactor: SessionUserFactor{
UserID: "user-id",
UserCheckedAt: testNow,
@ -240,6 +259,9 @@ func Test_SessionsPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
IntentFactor: SessionIntentFactor{
IntentCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
@ -255,6 +277,7 @@ func Test_SessionsPrepare(t *testing.T) {
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator2",
Domain: "domain",
UserFactor: SessionUserFactor{
UserID: "user-id2",
UserCheckedAt: testNow,
@ -264,6 +287,9 @@ func Test_SessionsPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
IntentFactor: SessionIntentFactor{
IntentCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
@ -343,12 +369,14 @@ func Test_SessionPrepare(t *testing.T) {
domain.SessionStateActive,
"ro",
"creator",
"domain",
"user-id",
testNow,
"login-name",
"display-name",
testNow,
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
"tokenID",
},
@ -362,6 +390,7 @@ func Test_SessionPrepare(t *testing.T) {
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator",
Domain: "domain",
UserFactor: SessionUserFactor{
UserID: "user-id",
UserCheckedAt: testNow,
@ -371,6 +400,9 @@ func Test_SessionPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
IntentFactor: SessionIntentFactor{
IntentCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},

View File

@ -6,6 +6,7 @@ import (
"time"
sq "github.com/Masterminds/squirrel"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/call"
@ -64,12 +65,27 @@ var (
name: projection.UserAuthMethodOwnerRemovedCol,
table: userAuthMethodTable,
}
authMethodTypeTable = userAuthMethodTable.setAlias("auth_method_types")
authMethodTypeUserID = UserAuthMethodColumnUserID.setTable(authMethodTypeTable)
authMethodTypeInstanceID = UserAuthMethodColumnInstanceID.setTable(authMethodTypeTable)
authMethodTypeTypes = UserAuthMethodColumnMethodType.setTable(authMethodTypeTable)
authMethodTypeState = UserAuthMethodColumnState.setTable(authMethodTypeTable)
userIDPsCountTable = idpUserLinkTable.setAlias("user_idps_count")
userIDPsCountUserID = IDPUserLinkUserIDCol.setTable(userIDPsCountTable)
userIDPsCountInstanceID = IDPUserLinkInstanceIDCol.setTable(userIDPsCountTable)
userIDPsCountCount = Column{
name: "count",
table: userIDPsCountTable,
}
)
type AuthMethods struct {
SearchResponse
AuthMethods []*AuthMethod
}
type AuthMethod struct {
UserID string
CreationDate time.Time
@ -83,6 +99,11 @@ type AuthMethod struct {
Type domain.UserAuthMethodType
}
type AuthMethodTypes struct {
SearchResponse
AuthMethodTypes []domain.UserAuthMethodType
}
type UserAuthMethodSearchQueries struct {
SearchRequest
Queries []SearchQuery
@ -114,6 +135,41 @@ func (q *Queries) SearchUserAuthMethods(ctx context.Context, queries *UserAuthMe
return userAuthMethods, err
}
func (q *Queries) ListActiveUserAuthMethodTypes(ctx context.Context, userID string, withOwnerRemoved bool) (userAuthMethodTypes *AuthMethodTypes, err error) {
ctxData := authz.GetCtxData(ctx)
if ctxData.UserID != userID {
if err := q.checkPermission(ctx, domain.PermissionUserRead, ctxData.OrgID, userID); err != nil {
return nil, err
}
}
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
query, scan := prepareActiveUserAuthMethodTypesQuery(ctx, q.client)
eq := sq.Eq{
UserIDCol.identifier(): userID,
UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(),
}
if !withOwnerRemoved {
eq[UserOwnerRemovedCol.identifier()] = false
}
stmt, args, err := query.Where(eq).ToSql()
if err != nil {
return nil, errors.ThrowInvalidArgument(err, "QUERY-Sfdrg", "Errors.Query.InvalidRequest")
}
rows, err := q.client.QueryContext(ctx, stmt, args...)
if err != nil || rows.Err() != nil {
return nil, errors.ThrowInternal(err, "QUERY-SDgr3", "Errors.Internal")
}
userAuthMethodTypes, err = scan(rows)
if err != nil {
return nil, err
}
userAuthMethodTypes.LatestSequence, err = q.latestSequence(ctx, userTable, notifyTable, userAuthMethodTable, idpUserLinkTable)
return userAuthMethodTypes, err
}
func NewUserAuthMethodUserIDSearchQuery(value string) (SearchQuery, error) {
return NewTextQuery(UserAuthMethodColumnUserID, value, TextEquals)
}
@ -253,3 +309,80 @@ func prepareUserAuthMethodsQuery(ctx context.Context, db prepareDatabase) (sq.Se
}, nil
}
}
func prepareActiveUserAuthMethodTypesQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*AuthMethodTypes, error)) {
authMethodsQuery, authMethodsArgs, err := sq.Select(
"DISTINCT("+authMethodTypeTypes.identifier()+")",
authMethodTypeUserID.identifier(),
authMethodTypeInstanceID.identifier()).
From(authMethodTypeTable.identifier()).
Where(sq.Eq{authMethodTypeState.identifier(): domain.MFAStateReady}).
ToSql()
if err != nil {
return sq.SelectBuilder{}, nil
}
idpsQuery, _, err := sq.Select(
userIDPsCountUserID.identifier(),
userIDPsCountInstanceID.identifier(),
"COUNT("+userIDPsCountUserID.identifier()+") AS "+userIDPsCountCount.name).
From(userIDPsCountTable.identifier()).
GroupBy(
userIDPsCountUserID.identifier(),
userIDPsCountInstanceID.identifier(),
).
ToSql()
if err != nil {
return sq.SelectBuilder{}, nil
}
return sq.Select(
NotifyPasswordSetCol.identifier(),
authMethodTypeTypes.identifier(),
userIDPsCountCount.identifier()).
From(userTable.identifier()).
LeftJoin(join(NotifyUserIDCol, UserIDCol)).
LeftJoin("("+authMethodsQuery+") AS "+authMethodTypeTable.alias+" ON "+
authMethodTypeUserID.identifier()+" = "+UserIDCol.identifier()+" AND "+
authMethodTypeInstanceID.identifier()+" = "+UserInstanceIDCol.identifier(),
authMethodsArgs...).
LeftJoin("(" + idpsQuery + ") AS " + userIDPsCountTable.alias + " ON " +
userIDPsCountUserID.identifier() + " = " + UserIDCol.identifier() + " AND " +
userIDPsCountInstanceID.identifier() + " = " + UserInstanceIDCol.identifier() + db.Timetravel(call.Took(ctx))).
PlaceholderFormat(sq.Dollar),
func(rows *sql.Rows) (*AuthMethodTypes, error) {
userAuthMethodTypes := make([]domain.UserAuthMethodType, 0)
var passwordSet sql.NullBool
var idp sql.NullInt64
for rows.Next() {
var authMethodType sql.NullInt16
err := rows.Scan(
&passwordSet,
&authMethodType,
&idp,
)
if err != nil {
return nil, err
}
if authMethodType.Valid {
userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodType(authMethodType.Int16))
}
}
if passwordSet.Valid && passwordSet.Bool {
userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodTypePassword)
}
if idp.Valid && idp.Int64 > 0 {
logging.Error("IDP", idp.Int64)
userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodTypeIDP)
}
if err := rows.Close(); err != nil {
return nil, errors.ThrowInternal(err, "QUERY-3n9fl", "Errors.Query.CloseRows")
}
return &AuthMethodTypes{
AuthMethodTypes: userAuthMethodTypes,
SearchResponse: SearchResponse{
Count: uint64(len(userAuthMethodTypes)),
},
}, nil
}
}

View File

@ -36,6 +36,23 @@ var (
"method_type",
"count",
}
prepareActiveAuthMethodTypesStmt = `SELECT projections.users8_notifications.password_set,` +
` auth_method_types.method_type,` +
` user_idps_count.count` +
` FROM projections.users8` +
` LEFT JOIN projections.users8_notifications ON projections.users8.id = projections.users8_notifications.user_id AND projections.users8.instance_id = projections.users8_notifications.instance_id` +
` LEFT JOIN (SELECT DISTINCT(auth_method_types.method_type), auth_method_types.user_id, auth_method_types.instance_id FROM projections.user_auth_methods4 AS auth_method_types` +
` WHERE auth_method_types.state = $1) AS auth_method_types` +
` ON auth_method_types.user_id = projections.users8.id AND auth_method_types.instance_id = projections.users8.instance_id` +
` LEFT JOIN (SELECT user_idps_count.user_id, user_idps_count.instance_id, COUNT(user_idps_count.user_id) AS count FROM projections.idp_user_links3 AS user_idps_count` +
` GROUP BY user_idps_count.user_id, user_idps_count.instance_id) AS user_idps_count` +
` ON user_idps_count.user_id = projections.users8.id AND user_idps_count.instance_id = projections.users8.instance_id` +
` AS OF SYSTEM TIME '-1 ms`
prepareActiveAuthMethodTypesCols = []string{
"password_set",
"method_type",
"idps_count",
}
)
func Test_UserAuthMethodPrepares(t *testing.T) {
@ -182,6 +199,95 @@ func Test_UserAuthMethodPrepares(t *testing.T) {
},
object: nil,
},
{
name: "prepareActiveUserAuthMethodTypesQuery no result",
prepare: prepareActiveUserAuthMethodTypesQuery,
want: want{
sqlExpectations: mockQueries(
regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt),
nil,
nil,
),
},
object: &AuthMethodTypes{AuthMethodTypes: []domain.UserAuthMethodType{}},
},
{
name: "prepareActiveUserAuthMethodTypesQuery one second factor",
prepare: prepareActiveUserAuthMethodTypesQuery,
want: want{
sqlExpectations: mockQueries(
regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt),
prepareActiveAuthMethodTypesCols,
[][]driver.Value{
{
true,
domain.UserAuthMethodTypePasswordless,
1,
},
},
),
},
object: &AuthMethodTypes{
SearchResponse: SearchResponse{
Count: 3,
},
AuthMethodTypes: []domain.UserAuthMethodType{
domain.UserAuthMethodTypePasswordless,
domain.UserAuthMethodTypePassword,
domain.UserAuthMethodTypeIDP,
},
},
},
{
name: "prepareActiveUserAuthMethodTypesQuery multiple second factors",
prepare: prepareActiveUserAuthMethodTypesQuery,
want: want{
sqlExpectations: mockQueries(
regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt),
prepareActiveAuthMethodTypesCols,
[][]driver.Value{
{
true,
domain.UserAuthMethodTypePasswordless,
1,
},
{
true,
domain.UserAuthMethodTypeOTP,
1,
},
},
),
},
object: &AuthMethodTypes{
SearchResponse: SearchResponse{
Count: 4,
},
AuthMethodTypes: []domain.UserAuthMethodType{
domain.UserAuthMethodTypePasswordless,
domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypePassword,
domain.UserAuthMethodTypeIDP,
},
},
},
{
name: "prepareActiveUserAuthMethodTypesQuery sql err",
prepare: prepareActiveUserAuthMethodTypesQuery,
want: want{
sqlExpectations: mockQueryErr(
regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt),
sql.ErrConnDone,
),
err: func(err error) (error, bool) {
if !errors.Is(err, sql.ErrConnDone) {
return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false
}
return nil, true
},
},
object: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -69,6 +69,8 @@ type SucceededEvent struct {
eventstore.BaseEvent `json:"-"`
IDPUser []byte `json:"idpUser"`
IDPUserID string `json:"idpUserId,omitempty"`
IDPUserName string `json:"idpUserName,omitempty"`
UserID string `json:"userId,omitempty"`
IDPAccessToken *crypto.CryptoValue `json:"idpAccessToken,omitempty"`
IDPIDToken string `json:"idpIdToken,omitempty"`
@ -78,10 +80,12 @@ func NewSucceededEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
idpUser []byte,
idpUserID,
idpUserName,
userID string,
idpAccessToken *crypto.CryptoValue,
idpIDToken string,
) (*SucceededEvent, error) {
) *SucceededEvent {
return &SucceededEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
@ -89,10 +93,12 @@ func NewSucceededEvent(
SucceededEventType,
),
IDPUser: idpUser,
IDPUserID: idpUserID,
IDPUserName: idpUserName,
UserID: userID,
IDPAccessToken: idpAccessToken,
IDPIDToken: idpIDToken,
}, nil
}
}
func (e *SucceededEvent) Data() interface{} {

View File

@ -6,6 +6,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper).
RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, IntentCheckedType, IntentCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, PasskeyChallengedType, eventstore.GenericEventMapper[PasskeyChallengedEvent]).
RegisterFilterEventMapper(AggregateType, PasskeyCheckedType, eventstore.GenericEventMapper[PasskeyCheckedEvent]).
RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper).

View File

@ -16,6 +16,7 @@ const (
AddedType = sessionEventPrefix + "added"
UserCheckedType = sessionEventPrefix + "user.checked"
PasswordCheckedType = sessionEventPrefix + "password.checked"
IntentCheckedType = sessionEventPrefix + "intent.checked"
PasskeyChallengedType = sessionEventPrefix + "passkey.challenged"
PasskeyCheckedType = sessionEventPrefix + "passkey.checked"
TokenSetType = sessionEventPrefix + "token.set"
@ -25,6 +26,8 @@ const (
type AddedEvent struct {
eventstore.BaseEvent `json:"-"`
Domain string `json:"domain,omitempty"`
}
func (e *AddedEvent) Data() interface{} {
@ -37,6 +40,7 @@ func (e *AddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
func NewAddedEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
domain string,
) *AddedEvent {
return &AddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
@ -44,6 +48,7 @@ func NewAddedEvent(ctx context.Context,
aggregate,
AddedType,
),
Domain: domain,
}
}
@ -144,6 +149,47 @@ func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, erro
return added, nil
}
type IntentCheckedEvent struct {
eventstore.BaseEvent `json:"-"`
CheckedAt time.Time `json:"checkedAt"`
}
func (e *IntentCheckedEvent) Data() interface{} {
return e
}
func (e *IntentCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewIntentCheckedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
checkedAt time.Time,
) *IntentCheckedEvent {
return &IntentCheckedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
IntentCheckedType,
),
CheckedAt: checkedAt,
}
}
func IntentCheckedEventMapper(event *repository.Event) (eventstore.Event, error) {
added := &IntentCheckedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, added)
if err != nil {
return nil, errors.ThrowInternal(err, "SESSION-DGt90", "unable to unmarshal intent checked")
}
return added, nil
}
type PasskeyChallengedEvent struct {
eventstore.BaseEvent `json:"-"`

View File

@ -39,6 +39,7 @@ func NewHumanPasswordlessAddedEvent(
aggregate *eventstore.Aggregate,
webAuthNTokenID,
challenge string,
rpID string,
) *HumanPasswordlessAddedEvent {
return &HumanPasswordlessAddedEvent{
HumanWebAuthNAddedEvent: *NewHumanWebAuthNAddedEvent(
@ -49,6 +50,7 @@ func NewHumanPasswordlessAddedEvent(
),
webAuthNTokenID,
challenge,
rpID,
),
}
}

View File

@ -28,6 +28,7 @@ func NewHumanU2FAddedEvent(
aggregate *eventstore.Aggregate,
webAuthNTokenID,
challenge string,
rpID string,
) *HumanU2FAddedEvent {
return &HumanU2FAddedEvent{
HumanWebAuthNAddedEvent: *NewHumanWebAuthNAddedEvent(
@ -38,6 +39,7 @@ func NewHumanU2FAddedEvent(
),
webAuthNTokenID,
challenge,
rpID,
),
}
}

View File

@ -14,6 +14,7 @@ type HumanWebAuthNAddedEvent struct {
WebAuthNTokenID string `json:"webAuthNTokenId"`
Challenge string `json:"challenge"`
RPID string `json:"rpID,omitempty"`
}
func (e *HumanWebAuthNAddedEvent) Data() interface{} {
@ -28,11 +29,13 @@ func NewHumanWebAuthNAddedEvent(
base *eventstore.BaseEvent,
webAuthNTokenID,
challenge string,
rpID string,
) *HumanWebAuthNAddedEvent {
return &HumanWebAuthNAddedEvent{
BaseEvent: *base,
WebAuthNTokenID: webAuthNTokenID,
Challenge: challenge,
RPID: rpID,
}
}

View File

@ -76,6 +76,8 @@ type HumanPasswordCodeAddedEvent struct {
Code *crypto.CryptoValue `json:"code,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"`
NotificationType domain.NotificationType `json:"notificationType,omitempty"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
}
func (e *HumanPasswordCodeAddedEvent) Data() interface{} {
@ -92,6 +94,18 @@ func NewHumanPasswordCodeAddedEvent(
code *crypto.CryptoValue,
expiry time.Duration,
notificationType domain.NotificationType,
) *HumanPasswordCodeAddedEvent {
return NewHumanPasswordCodeAddedEventV2(ctx, aggregate, code, expiry, notificationType, "", false)
}
func NewHumanPasswordCodeAddedEventV2(
ctx context.Context,
aggregate *eventstore.Aggregate,
code *crypto.CryptoValue,
expiry time.Duration,
notificationType domain.NotificationType,
urlTemplate string,
codeReturned bool,
) *HumanPasswordCodeAddedEvent {
return &HumanPasswordCodeAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
@ -102,6 +116,8 @@ func NewHumanPasswordCodeAddedEvent(
Code: code,
Expiry: expiry,
NotificationType: notificationType,
URLTemplate: urlTemplate,
CodeReturned: codeReturned,
}
}

Some files were not shown because too many files have changed in this diff Show More