feat: notifications (#109)

* implement notification providers

* email provider

* notification handler

* notify users

* implement code sent on user eventstore

* send email implementation

* send init code

* handle codes

* fix project member handler

* add some logs for debug

* send emails

* text changes

* send sms

* notification process

* send password code

* format phone number

* test format phone

* remove fmts

* remove unused code

* rename files

* add mocks

* merge master

* Update internal/notification/providers/email/message.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/providers/email/provider.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* requested changes of mr

* move locker to eventstore pkg

* Update internal/notification/providers/chat/message.go

Co-authored-by: Livio Amstutz <livio.a@gmail.com>

* move locker to eventstore pkg

* linebreak

* Update internal/notification/providers/email/provider.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

* Update internal/notification/repository/eventsourcing/handler/notification.go

Co-authored-by: Silvan <silvan.reusser@gmail.com>

Co-authored-by: Silvan <silvan.reusser@gmail.com>
Co-authored-by: Livio Amstutz <livio.a@gmail.com>
This commit is contained in:
Fabi 2020-05-20 14:28:08 +02:00 committed by GitHub
parent c365a98cc8
commit e318139b37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 3278 additions and 119 deletions

View File

@ -20,3 +20,19 @@ export ZITADEL_KEY_PATH="$BASEDIR/local_keys.yaml"
export ZITADEL_USER_VERIFICATION_KEY=UserVerificationKey_1
export ZITADEL_OTP_VERIFICATION_KEY=OTPVerificationKey_1
# Notifications
export DEBUG_MODE=TRUE
export TWILIO_SERVICE_SID=$(gopass citadel-secrets/citadel/developer/default/twilio-sid)
export TWILIO_TOKEN=$(gopass citadel-secrets/citadel/developer/default/twilio-auth-token)
export TWILIO_SENDER_NAME=CAOS AG
export SMTP_HOST=smtp.gmail.com:465
export SMTP_USER=zitadel@caos.ch
export SMTP_PASSWORD=$(gopass citadel-secrets/citadel/google/emailappkey)
export EMAIL_SENDER_ADDRESS=noreply@caos.ch
export EMAIL_SENDER_NAME=CAOS AG
export SMTP_TLS=TRUE
export CHAT_URL=$(gopass citadel-secrets/citadel/developer/default/google-chat-url | base64 -D)
# Zitadel
export ZITADEL_ACCOUNTS=http://localhost:61121

View File

@ -9,6 +9,7 @@ import (
authz "github.com/caos/zitadel/internal/api/auth"
"github.com/caos/zitadel/internal/config"
"github.com/caos/zitadel/internal/notification"
tracing "github.com/caos/zitadel/internal/tracing/config"
"github.com/caos/zitadel/pkg/admin"
"github.com/caos/zitadel/pkg/auth"
@ -18,11 +19,12 @@ import (
)
type Config struct {
Mgmt management.Config
Auth auth.Config
Login login.Config
Admin admin.Config
Console console.Config
Mgmt management.Config
Auth auth.Config
Login login.Config
Admin admin.Config
Console console.Config
Notification notification.Config
Log logging.Config
Tracing tracing.TracingConfig
@ -38,6 +40,7 @@ func main() {
loginEnabled := flag.Bool("login", true, "enable login ui")
adminEnabled := flag.Bool("admin", true, "enable admin api")
consoleEnabled := flag.Bool("console", true, "enable console ui")
notificationEnabled := flag.Bool("notification", true, "enable notification handler")
flag.Parse()
conf := new(Config)
@ -58,6 +61,9 @@ func main() {
if *adminEnabled {
admin.Start(ctx, conf.Admin, conf.AuthZ, conf.SystemDefaults)
}
if *notificationEnabled {
notification.Start(ctx, conf.Notification, conf.SystemDefaults)
}
if *consoleEnabled {
err = console.Start(ctx, conf.Console)
logging.Log("MAIN-3Dfuc").OnError(err).Fatal("error starting console ui")

View File

@ -110,3 +110,33 @@ Admin:
Console:
Port: 50050
StaticDir: /app/console/dist
Notification:
Repository:
Eventstore:
ServiceName: 'Notification'
Repository:
SQL:
Host: $ZITADEL_EVENTSTORE_HOST
Port: $ZITADEL_EVENTSTORE_PORT
User: 'notification'
Database: 'eventstore'
SSLmode: disable
Cache:
Type: 'fastcache'
Config:
MaxCacheSizeInByte: 10485760 #10mb
View:
Host: $ZITADEL_EVENTSTORE_HOST
Port: $ZITADEL_EVENTSTORE_PORT
User: 'notification'
Database: 'notification'
SSLmode: disable
Spooler:
ConcurrentTasks: 4
BulkLimit: 100
FailureCountUntilSkip: 5
Handlers:
Notification:
MinimumCycleDuration: 10s

View File

@ -109,3 +109,54 @@ SystemDefaults:
AuthMethodType: 'AUTH_TYPE_NONE'
Owners:
- 'zitadel-admin@caos.ch'
Notifications:
DebugMode: $DEBUG_MODE
Endpoints:
InitCode: '$ZITADEL_ACCOUNTS/user/init?userID={{.UserID}}&code={{.Code}}'
PasswordReset: '$ZITADEL_ACCOUNTS/password/init?userID={{.UserID}}&code={{.Code}}'
VerifyEmail: '$ZITADEL_ACCOUNTS/mail/verification?userID={{.UserID}}&code={{.Code}}'
Providers:
Chat:
Url: $CHAT_URL
SplitCount: 4000
Email:
SMTP:
Host: $SMTP_HOST
User: $SMTP_USER
Password: $SMTP_PASSWORD
From: $EMAIL_SENDER_ADDRESS
FromName: $EMAIL_SENDER_NAME
Tls: $SMTP_TLS
Twilio:
SID: $TWILIO_SERVICE_SID
Token: $TWILIO_TOKEN
From: $TWILIO_SENDER_NAME
TemplateData:
InitCode:
Title: 'Zitadel - User Initialisieren'
PreHeader: 'User Initialisieren'
Subject: 'User Initialisieren'
Greeting: 'Hallo {{.FirstName}} {{.LastName}},'
Text: 'Dieser Benutzer wurde soeben im Zitadel erstellt. Du kannst den Button unten verwenden, um die Initialisierung abzuschliesen. Falls du dieses Mail nicht angefordert hast, kannst du es einfach ignorieren.'
ButtonText: 'Initialisierung abschliessen'
PasswordReset:
Title: 'Zitadel - Passwort zurücksetzen'
PreHeader: 'Passwort zurücksetzen'
Subject: 'Passwort zurücksetzen'
Greeting: 'Hallo {{.FirstName}} {{.LastName}},'
Text: 'Wir haben eine Anfrage für das Zurücksetzen deines Passwortes bekommen. Du kannst den untenstehenden Button verwenden, um dein Passwort zurückzusetzen. Falls du dieses Mail nicht angefordert hast, kannst du es ignorieren.'
ButtonText: 'Passwort zurücksetzen'
VerifyEmail:
Title: 'Zitadel - Email Verifizieren'
PreHeader: 'Email verifizieren'
Subject: 'Email verifizieren'
Greeting: 'Hallo {{.FirstName}} {{.LastName}},'
Text: 'Eine neue E-Mail Adresse wurde hinzugefügt. Bitte verwende den untenstehenden Button um diese zu verifizieren. Falls du deine E-Mail Adresse nicht selber hinzugefügt hast, kannst du dieses E-Mail ignorieren.'
ButtonText: 'Passwort zurücksetzen'
VerifyPhone:
Title: 'Zitadel - Telefonnummer Verifizieren'
PreHeader: 'Telefonnummer Verifizieren'
Subject: 'Telefonnummer Verifizieren'
Greeting: 'Hallo {{.FirstName}} {{.LastName}},'
Text: 'Eine Telefonnummer wurde hinzugefügt. Bitte verifiziere diese in dem du folgenden Code eingibst: {{.Code}}'
ButtonText: 'Telefon verifizieren'

451
cmd/zitadel/template.html Normal file
View File

@ -0,0 +1,451 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{.Title}}</title>
<style>
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400&display=swap');
@font-face {
font-family: Ailerons;
src: url("https://www.caos.ch/fonts/Ailerons-Typeface.otf") format("opentype");
}
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background: #222324 url('waves-bottomleft.png') bottom left no-repeat;
background-size: 150px;
font-family: 'Roboto', sans-serif;
font-weight: 300;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
table td {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 300;
vertical-align: top;
color: #fff;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background: url('waves-topright.png') top right no-repeat;
background-size: 150px;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 40px;
width: 580px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #191919;
width: 100%;
border-radius: 16px;
}
.wrapper {
box-sizing: border-box;
padding: 50px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center;
}
.footer a{
color: #FE00FF;
text-decoration: none;
font-size: 14px;
font-weight: 400;
}
.footer a:hover{
text-decoration: underline;
}
.apple-link{
margin-bottom: 10px;
display: inline-block;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #fff;
font-family: 'Roboto', sans-serif;
font-weight: 300;
line-height: 1.4;
margin: 0;
margin-bottom: 30px;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
}
p,
ul,
ol {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 300;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
color: #0FBDA6;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #0FBDA6;
border-radius: 5px;
box-sizing: border-box;
color: #0FBDA6;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: 400;
margin: 0;
padding: 20px 60px;
text-decoration: none;
}
.btn-primary {
margin-top: 50px;
}
.btn-primary table td {
background-color: #0FBDA6;
}
.btn-primary a {
background-color: #0FBDA6;
border-color: #0FBDA6;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.mheader {
background-color: #131313;
}
.headercell {
color: #FFFFFF;
}
.headertitle{
padding: 30px 30px 70px;
margin: 0;
font-size: 42px;
font-family: 'Ailerons', sans-serif;
text-align: center;
}
.logo{
height: 50px;
margin: 10px;
margin-left: 30px;
}
.hello {
font-size: 22px;
}
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.subject {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
margin: 20px 0;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
.btn-primary table td:hover {
background-color: #1ADFC5 !important;
}
.btn-primary a:hover {
background-color: #1ADFC5 !important;
border-color: #1ADFC5 !important;
}
}
</style>
</head>
<body class="">
<span class="preheader">{{.PreHeader}}</span>
<span class="subject">{{.Subject}}</span>
<table role="pheader" class="mheader" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="mheadercell">
</td>
<td class="logo">
<img class="logo" src="https://caos.ch/images/LogoCaos.png" alt="CAOS AG - Logo">
</td>
</tr>
</table>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td></td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="pheader" class="tableheader" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="headercell">
<p class="headertitle">Zitadel</p>
</td>
</tr>
</table>
<table role="presentation" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" class="maincontent" border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<p class="hello">{{.Greeting}}</p>
<p>{{.Text}}</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{.URL}}" target="_blank">{{.ButtonText}}</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<span class="apple-link">CAOS AG &nbsp;&nbsp; | &nbsp;&nbsp; Teufener Strasse 19 &nbsp;&nbsp; | &nbsp;&nbsp; CH-9000 St.Gallen</span>
<br> <a href="http://www.caos.ch">caos.ch</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

8
go.mod
View File

@ -27,8 +27,14 @@ require (
github.com/grpc-ecosystem/grpc-gateway v1.14.5
github.com/huandu/xstrings v1.3.1 // indirect
github.com/imdario/mergo v0.3.9 // indirect
github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 // indirect
github.com/jinzhu/gorm v1.9.12
github.com/kevinburke/go-types v0.0.0-20200309064045-f2d4aea18a7a // indirect
github.com/kevinburke/go.uuid v1.2.0 // indirect
github.com/kevinburke/rest v0.0.0-20200429221318-0d2892b400f8 // indirect
github.com/kevinburke/twilio-go v0.0.0-20200424172635-4f0b2357b852
github.com/lib/pq v1.5.2
github.com/mattn/go-colorable v0.1.6 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.1 // indirect
github.com/nicksnyder/go-i18n/v2 v2.0.3
@ -38,6 +44,8 @@ require (
github.com/sirupsen/logrus v1.6.0 // indirect
github.com/sony/sonyflake v1.0.0
github.com/stretchr/testify v1.5.1
github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect
github.com/ttacon/libphonenumber v1.1.0
go.opencensus.io v0.22.3
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f // indirect

20
go.sum
View File

@ -97,6 +97,7 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
@ -167,6 +168,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 h1:KUDFlmBg2buRWNzIcwLlKvfcnujcHQRQ1As1LoaCLAM=
github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q=
github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -179,6 +182,14 @@ github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2
github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kevinburke/go-types v0.0.0-20200309064045-f2d4aea18a7a h1:Z7+SSApKiwPjNic+NF9+j7h657Uyvdp/jA3iTKhpj4E=
github.com/kevinburke/go-types v0.0.0-20200309064045-f2d4aea18a7a/go.mod h1:/Pk5i/SqYdYv1cie5wGwoZ4P6TpgMi+Yf58mtJSHdOw=
github.com/kevinburke/go.uuid v1.2.0 h1:+1qP8NdkJfgOSTrrrUuA7h0djr1VY77HFXYjR+zUcUo=
github.com/kevinburke/go.uuid v1.2.0/go.mod h1:9gVngk1Hq1FjwewVAjsWEUT+xc6jP+p62CASaGmQ0NQ=
github.com/kevinburke/rest v0.0.0-20200429221318-0d2892b400f8 h1:KpuDJTaTPQAyWqETt70dHX3pMz65/XYTAZymrKKNvh8=
github.com/kevinburke/rest v0.0.0-20200429221318-0d2892b400f8/go.mod h1:pD+iEcdAGVXld5foVN4e24zb/6fnb60tgZPZ3P/3T/I=
github.com/kevinburke/twilio-go v0.0.0-20200424172635-4f0b2357b852 h1:wJMykIkD7A4tlwQNzqBJ23hkLlKtRKYeNNt+n8ASqWE=
github.com/kevinburke/twilio-go v0.0.0-20200424172635-4f0b2357b852/go.mod h1:Fm9alkN1/LPVY1eqD/psyMwPWE4VWl4P01/nTYZKzBk=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -195,6 +206,10 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.5.2 h1:yTSXVswvWUOQ3k1sd7vJfDrbSl8lKuscqFJRqjC0ifw=
github.com/lib/pq v1.5.2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
@ -234,6 +249,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 h1:5u+EJUQiosu3JFX0XS0qTf5FznsMOzTjGqavBGuCbo0=
github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2/go.mod h1:4kyMkleCiLkgY6z8gK5BkI01ChBtxR0ro3I1ZDcGM3w=
github.com/ttacon/libphonenumber v1.1.0 h1:tC6kE4t8UI4OqQVQjW5q8gSWhG2wnY5moEpSEORdYm4=
github.com/ttacon/libphonenumber v1.1.0/go.mod h1:E0TpmdVMq5dyVlQ7oenAkhsLu86OkUl+yR4OAxyEg/M=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@ -343,6 +362,7 @@ golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab h1:FvshnhkKW+LO3HWHodML8kuVX
golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -1,46 +1,19 @@
package spooler
import (
"context"
"database/sql"
"fmt"
caos_errs "github.com/caos/zitadel/internal/errors"
es_locker "github.com/caos/zitadel/internal/eventstore/locker"
"time"
"github.com/cockroachdb/cockroach-go/crdb"
)
const (
lockTable = "auth.locks"
lockedUntilKey = "locked_until"
lockerIDKey = "locker_id"
objectTypeKey = "object_type"
lockTable = "auth.locks"
)
type locker struct {
dbClient *sql.DB
}
type lock struct {
LockerID string `gorm:"column:locker_id;primary_key"`
LockedUntil time.Time `gorm:"column:locked_until"`
ViewName string `gorm:"column:object_type;primary_key"`
}
func (l *locker) Renew(lockerID, viewModel string, waitTime time.Duration) error {
return crdb.ExecuteTx(context.Background(), l.dbClient, nil, func(tx *sql.Tx) error {
query := fmt.Sprintf("INSERT INTO %s (%s, %s, %s) VALUES ($1, $2, now()+$3) ON CONFLICT (%s) DO UPDATE SET %s = now()+$4, %s = $5 WHERE (locks.%s < now() OR locks.%s = $6) AND locks.%s = $7",
lockTable, objectTypeKey, lockerIDKey, lockedUntilKey, objectTypeKey, lockedUntilKey, lockerIDKey, lockedUntilKey, lockerIDKey, objectTypeKey)
rs, err := tx.Exec(query, viewModel, lockerID, waitTime.Seconds(), waitTime.Seconds(), lockerID, lockerID, viewModel)
if err != nil {
tx.Rollback()
return err
}
if rows, _ := rs.RowsAffected(); rows == 0 {
tx.Rollback()
return caos_errs.ThrowAlreadyExists(nil, "SPOOL-lso0e", "view already locked")
}
return nil
})
return es_locker.Renew(l.dbClient, lockTable, lockerID, viewModel, waitTime)
}

View File

@ -3,6 +3,10 @@ package systemdefaults
import (
"github.com/caos/zitadel/internal/config/types"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/notification/providers/chat"
"github.com/caos/zitadel/internal/notification/providers/email"
"github.com/caos/zitadel/internal/notification/providers/twilio"
"github.com/caos/zitadel/internal/notification/templates"
pol "github.com/caos/zitadel/internal/policy"
)
@ -14,6 +18,7 @@ type SystemDefaults struct {
DefaultPolicies DefaultPolicies
IamID string
SetUp types.IAMSetUp
Notifications Notifications
}
type SecretGenerators struct {
@ -46,3 +51,29 @@ type DefaultPolicies struct {
Complexity pol.PasswordComplexityPolicyDefault
Lockout pol.PasswordLockoutPolicyDefault
}
type Notifications struct {
DebugMode bool
Endpoints Endpoints
Providers Providers
TemplateData TemplateData
}
type Endpoints struct {
InitCode string
PasswordReset string
VerifyEmail string
}
type Providers struct {
Chat chat.ChatConfig
Email email.EmailConfig
Twilio twilio.TwilioConfig
}
type TemplateData struct {
InitCode templates.TemplateData
PasswordReset templates.TemplateData
VerifyEmail templates.TemplateData
VerifyPhone templates.TemplateData
}

View File

@ -0,0 +1,40 @@
package locker
import (
"context"
"database/sql"
"fmt"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/cockroachdb/cockroach-go/crdb"
"time"
)
const (
lockedUntilKey = "locked_until"
lockerIDKey = "locker_id"
objectTypeKey = "object_type"
)
type lock struct {
LockerID string `gorm:"column:locker_id;primary_key"`
LockedUntil time.Time `gorm:"column:locked_until"`
ViewName string `gorm:"column:object_type;primary_key"`
}
func Renew(dbClient *sql.DB, lockTable, lockerID, viewModel string, waitTime time.Duration) error {
return crdb.ExecuteTx(context.Background(), dbClient, nil, func(tx *sql.Tx) error {
query := fmt.Sprintf("INSERT INTO %s (%s, %s, %s) VALUES ($1, $2, now()+$3) ON CONFLICT (%s) DO UPDATE SET %s = now()+$4, %s = $5 WHERE (locks.%s < now() OR locks.%s = $6) AND locks.%s = $7",
lockTable, objectTypeKey, lockerIDKey, lockedUntilKey, objectTypeKey, lockedUntilKey, lockerIDKey, lockedUntilKey, lockerIDKey, objectTypeKey)
rs, err := tx.Exec(query, viewModel, lockerID, waitTime.Seconds(), waitTime.Seconds(), lockerID, lockerID, viewModel)
if err != nil {
tx.Rollback()
return err
}
if rows, _ := rs.RowsAffected(); rows == 0 {
tx.Rollback()
return caos_errs.ThrowAlreadyExists(nil, "SPOOL-lso0e", "view already locked")
}
return nil
})
}

View File

@ -0,0 +1,125 @@
package locker
import (
"database/sql"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
type dbMock struct {
db *sql.DB
mock sqlmock.Sqlmock
}
func mockDB(t *testing.T) *dbMock {
mockDB := dbMock{}
var err error
mockDB.db, mockDB.mock, err = sqlmock.New()
if err != nil {
t.Fatalf("error occured while creating stub db %v", err)
}
mockDB.mock.MatchExpectationsInOrder(true)
return &mockDB
}
func (db *dbMock) expectCommit() *dbMock {
db.mock.ExpectCommit()
return db
}
func (db *dbMock) expectRollback() *dbMock {
db.mock.ExpectRollback()
return db
}
func (db *dbMock) expectBegin() *dbMock {
db.mock.ExpectBegin()
return db
}
func (db *dbMock) expectSavepoint() *dbMock {
db.mock.ExpectExec("SAVEPOINT").WillReturnResult(sqlmock.NewResult(1, 1))
return db
}
func (db *dbMock) expectReleaseSavepoint() *dbMock {
db.mock.ExpectExec("RELEASE SAVEPOINT").WillReturnResult(sqlmock.NewResult(1, 1))
return db
}
func (db *dbMock) expectRenew(lockerID, view string, affectedRows int64) *dbMock {
query := db.mock.
ExpectExec(`INSERT INTO table\.locks \(object_type, locker_id, locked_until\) VALUES \(\$1, \$2, now\(\)\+\$3\) ON CONFLICT \(object_type\) DO UPDATE SET locked_until = now\(\)\+\$4, locker_id = \$5 WHERE \(locks\.locked_until < now\(\) OR locks\.locker_id = \$6\) AND locks\.object_type = \$7`).
WithArgs(view, lockerID, sqlmock.AnyArg(), sqlmock.AnyArg(), lockerID, lockerID, view).
WillReturnResult(sqlmock.NewResult(1, 1))
if affectedRows == 0 {
query.WillReturnResult(sqlmock.NewResult(0, 0))
} else {
query.WillReturnResult(sqlmock.NewResult(1, affectedRows))
}
return db
}
func Test_locker_Renew(t *testing.T) {
type fields struct {
db *dbMock
}
type args struct {
tableName string
lockerID string
viewModel string
waitTime time.Duration
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{
name: "renew succeeded",
fields: fields{
db: mockDB(t).
expectBegin().
expectSavepoint().
expectRenew("locker", "view", 1).
expectReleaseSavepoint().
expectCommit(),
},
args: args{tableName: "table.locks", lockerID: "locker", viewModel: "view", waitTime: 1 * time.Second},
wantErr: false,
},
{
name: "renew now rows updated",
fields: fields{
db: mockDB(t).
expectBegin().
expectSavepoint().
expectRenew("locker", "view", 0).
expectRollback(),
},
args: args{tableName: "table.locks", lockerID: "locker", viewModel: "view", waitTime: 1 * time.Second},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := Renew(tt.fields.db.db, tt.args.tableName, tt.args.lockerID, tt.args.viewModel, tt.args.waitTime); (err != nil) != tt.wantErr {
t.Errorf("locker.Renew() error = %v, wantErr %v", err, tt.wantErr)
}
if err := tt.fields.db.mock.ExpectationsWereMet(); err != nil {
t.Errorf("not all database expectations met: %v", err)
}
})
}
}

View File

@ -1,6 +1,7 @@
package handler
import (
"github.com/caos/zitadel/internal/config/types"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/management/repository/eventsourcing/view"
@ -12,7 +13,7 @@ import (
type Configs map[string]*Config
type Config struct {
MinimumCycleDurationMillisecond int
MinimumCycleDuration types.Duration
}
type handler struct {
@ -31,8 +32,8 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, ev
return []spooler.Handler{
&GrantedProject{handler: handler{view, bulkLimit, configs.cycleDuration("GrantedProject"), errorCount}, eventstore: eventstore, projectEvents: repos.ProjectEvents},
&ProjectRole{handler: handler{view, bulkLimit, configs.cycleDuration("ProjectRole"), errorCount}, projectEvents: repos.ProjectEvents},
&ProjectMember{handler: handler{view, bulkLimit, configs.cycleDuration("ProjectMember"), errorCount}},
&ProjectGrantMember{handler: handler{view, bulkLimit, configs.cycleDuration("ProjectGrantMember"), errorCount}},
&ProjectMember{handler: handler{view, bulkLimit, configs.cycleDuration("ProjectMember"), errorCount}, userEvents: repos.UserEvents},
&ProjectGrantMember{handler: handler{view, bulkLimit, configs.cycleDuration("ProjectGrantMember"), errorCount}, userEvents: repos.UserEvents},
&Application{handler: handler{view, bulkLimit, configs.cycleDuration("Application"), errorCount}},
&User{handler: handler{view, bulkLimit, configs.cycleDuration("User"), errorCount}},
&UserGrant{handler: handler{view, bulkLimit, configs.cycleDuration("UserGrant"), errorCount}, projectEvents: repos.ProjectEvents, userEvents: repos.UserEvents},
@ -44,5 +45,5 @@ func (configs Configs) cycleDuration(viewModel string) time.Duration {
if !ok {
return 1 * time.Second
}
return time.Duration(c.MinimumCycleDurationMillisecond) * time.Millisecond
return c.MinimumCycleDuration.Duration
}

View File

@ -79,7 +79,7 @@ func Start(conf Config, systemDefaults sd.SystemDefaults) (*EsRepository, error)
}
org := es_org.StartOrg(es_org.OrgConfig{Eventstore: es})
eventstoreRepos := handler.EventstoreRepos{ProjectEvents: project}
eventstoreRepos := handler.EventstoreRepos{ProjectEvents: project, UserEvents: user}
spool := spooler.StartSpooler(conf.Spooler, es, view, sqlClient, eventstoreRepos)
return &EsRepository{

View File

@ -1,46 +1,19 @@
package spooler
import (
"context"
"database/sql"
"fmt"
caos_errs "github.com/caos/zitadel/internal/errors"
es_locker "github.com/caos/zitadel/internal/eventstore/locker"
"time"
"github.com/cockroachdb/cockroach-go/crdb"
)
const (
lockTable = "management.locks"
lockedUntilKey = "locked_until"
lockerIDKey = "locker_id"
objectTypeKey = "object_type"
lockTable = "management.locks"
)
type locker struct {
dbClient *sql.DB
}
type lock struct {
LockerID string `gorm:"column:locker_id;primary_key"`
LockedUntil time.Time `gorm:"column:locked_until"`
ViewName string `gorm:"column:object_type;primary_key"`
}
func (l *locker) Renew(lockerID, viewModel string, waitTime time.Duration) error {
return crdb.ExecuteTx(context.Background(), l.dbClient, nil, func(tx *sql.Tx) error {
query := fmt.Sprintf("INSERT INTO %s (%s, %s, %s) VALUES ($1, $2, now()+$3) ON CONFLICT (%s) DO UPDATE SET %s = now()+$4, %s = $5 WHERE (locks.%s < now() OR locks.%s = $6) AND locks.%s = $7",
lockTable, objectTypeKey, lockerIDKey, lockedUntilKey, objectTypeKey, lockedUntilKey, lockerIDKey, lockedUntilKey, lockerIDKey, objectTypeKey)
rs, err := tx.Exec(query, viewModel, lockerID, waitTime.Seconds(), waitTime.Seconds(), lockerID, lockerID, viewModel)
if err != nil {
tx.Rollback()
return err
}
if rows, _ := rs.RowsAffected(); rows == 0 {
tx.Rollback()
return caos_errs.ThrowAlreadyExists(nil, "SPOOL-lso0e", "view already locked")
}
return nil
})
return es_locker.Renew(l.dbClient, lockTable, lockerID, viewModel, waitTime)
}

View File

@ -0,0 +1,17 @@
package notification
import (
"context"
"github.com/caos/logging"
sd "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing"
)
type Config struct {
Repository eventsourcing.Config
}
func Start(ctx context.Context, config Config, systemDefaults sd.SystemDefaults) {
_, err := eventsourcing.Start(config.Repository, systemDefaults)
logging.Log("MAIN-9uBxp").OnError(err).Panic("unable to start app")
}

View File

@ -0,0 +1,6 @@
package chat
type ChatConfig struct {
Url string
SplitCount int
}

View File

@ -0,0 +1,9 @@
package chat
type ChatMessage struct {
Text string `json:"text"`
}
func (msg *ChatMessage) GetContent() string {
return msg.Text
}

View File

@ -0,0 +1,75 @@
package chat
import (
"bytes"
"encoding/json"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"net/http"
"net/url"
"unicode/utf8"
)
type Chat struct {
URL *url.URL
SplitCount int
}
func InitChatProvider(config ChatConfig) (*Chat, error) {
url, err := url.Parse(config.Url)
if err != nil {
return nil, err
}
return &Chat{
URL: url,
SplitCount: config.SplitCount,
}, nil
}
func (chat *Chat) CanHandleMessage(_ providers.Message) bool {
return true
}
func (chat *Chat) HandleMessage(message providers.Message) error {
contentText := message.GetContent()
for _, splittedMsg := range splitMessage(contentText, chat.SplitCount) {
chatMsg := &ChatMessage{Text: splittedMsg}
if err := chat.SendMessage(chatMsg); err != nil {
return err
}
}
return nil
}
func (chat *Chat) SendMessage(message providers.Message) error {
chatMsg, ok := message.(*ChatMessage)
if !ok {
return caos_errs.ThrowInternal(nil, "EMAIL-s8JLs", "message is not ChatMessage")
}
req, err := json.Marshal(chatMsg)
if err != nil {
return caos_errs.ThrowInternal(err, "PROVI-s8uie", "Could not unmarshal content")
}
_, err = http.Post(chat.URL.String(), "application/json; charset=UTF-8", bytes.NewReader(req))
if err != nil {
return caos_errs.ThrowInternal(err, "PROVI-si93s", "unable to send message")
}
return nil
}
func splitMessage(message string, count int) []string {
if count == 0 {
return []string{message}
}
var splits []string
var l, r int
for l, r = 0, count; r < len(message); l, r = r, r+count {
for !utf8.RuneStart(message[r]) {
r--
}
splits = append(splits, message[l:r])
}
splits = append(splits, message[l:])
return splits
}

View File

@ -0,0 +1,18 @@
package email
type EmailConfig struct {
SMTP SMTP
Tls bool
From string
FromName string
}
type SMTP struct {
Host string
User string
Password string
}
func (smtp *SMTP) HasAuth() bool {
return smtp.User != "" && smtp.Password != ""
}

View File

@ -0,0 +1,47 @@
package email
import (
"fmt"
"regexp"
"strings"
)
var (
isHTMLRgx = regexp.MustCompile(`.*<html.*>.*`)
lineBreak = "\r\n"
)
type EmailMessage struct {
Recipients []string
BCC []string
CC []string
SenderEmail string
Subject string
Content string
}
func (msg *EmailMessage) GetContent() string {
headers := make(map[string]string)
headers["From"] = msg.SenderEmail
headers["To"] = strings.Join(msg.Recipients, ", ")
headers["Cc"] = strings.Join(msg.CC, ", ")
message := ""
for k, v := range headers {
message += fmt.Sprintf("%s: %s"+lineBreak, k, v)
}
//default mime-type is html
mime := "MIME-version: 1.0;" + lineBreak + "Content-Type: text/html; charset=\"UTF-8\";" + lineBreak + lineBreak
if !isHTML(msg.Content) {
mime = "MIME-version: 1.0;" + lineBreak + "Content-Type: text/plain; charset=\"UTF-8\";" + lineBreak + lineBreak
}
subject := "Subject: " + msg.Subject + lineBreak
message += subject + mime + lineBreak + msg.Content
return message
}
func isHTML(input string) bool {
return isHTMLRgx.MatchString(input)
}

View File

@ -0,0 +1,123 @@
package email
import (
"crypto/tls"
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"net"
"net/smtp"
)
type Email struct {
smtpClient *smtp.Client
}
func InitEmailProvider(config EmailConfig) (*Email, error) {
client, err := config.SMTP.connectToSMTP(config.Tls)
if err != nil {
return nil, err
}
return &Email{
smtpClient: client,
}, nil
}
func (email *Email) CanHandleMessage(message providers.Message) bool {
msg, ok := message.(*EmailMessage)
if !ok {
return false
}
return msg.Content != "" && msg.Subject != "" && len(msg.Recipients) > 0
}
func (email *Email) HandleMessage(message providers.Message) error {
defer email.smtpClient.Close()
emailMsg, ok := message.(*EmailMessage)
if !ok {
return caos_errs.ThrowInternal(nil, "EMAIL-s8JLs", "message is not EmailMessage")
}
// To && From
if err := email.smtpClient.Mail(emailMsg.SenderEmail); err != nil {
return caos_errs.ThrowInternalf(err, "EMAIL-s3is3", "could not set sender: %v", emailMsg.SenderEmail)
}
for _, recp := range append(append(emailMsg.Recipients, emailMsg.CC...), emailMsg.BCC...) {
if err := email.smtpClient.Rcpt(recp); err != nil {
return caos_errs.ThrowInternalf(err, "EMAIL-s4is4", "could not set recipient: %v", recp)
}
}
// Data
w, err := email.smtpClient.Data()
if err != nil {
return err
}
_, err = w.Write([]byte(emailMsg.GetContent()))
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
defer logging.LogWithFields("EMAI-a1c87ec8").Debug("email sent")
return email.smtpClient.Quit()
}
func (smtpConfig SMTP) connectToSMTP(tlsRequired bool) (client *smtp.Client, err error) {
host, _, err := net.SplitHostPort(smtpConfig.Host)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "EMAIL-spR56", "could not split host and port for connect to smtp")
}
if !tlsRequired {
client, err = smtpConfig.getSMPTClient()
} else {
client, err = smtpConfig.getSMPTClientWithTls(host)
}
if err != nil {
return nil, err
}
err = smtpConfig.smtpAuth(client, host)
if err != nil {
return nil, err
}
return client, nil
}
func (smtpConfig SMTP) getSMPTClient() (*smtp.Client, error) {
client, err := smtp.Dial(smtpConfig.Host)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "EMAIL-skwos", "Could not make smtp dial")
}
return client, nil
}
func (smtpConfig SMTP) getSMPTClientWithTls(host string) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", smtpConfig.Host, &tls.Config{})
if err != nil {
return nil, caos_errs.ThrowInternal(err, "EMAIL-sl39s", "Could not make tls dial")
}
client, err := smtp.NewClient(conn, host)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "EMAIL-skwi4", "Could not create smtp client")
}
return client, err
}
func (smtpConfig SMTP) smtpAuth(client *smtp.Client, host string) error {
if !smtpConfig.HasAuth() {
return nil
}
// Auth
auth := smtp.PlainAuth("", smtpConfig.User, smtpConfig.Password, host)
err := client.Auth(auth)
logging.Log("EMAIL-s9kfs").WithField("smtp user", smtpConfig.User).OnError(err).Debug("Could not add smtp auth")
return err
}

View File

@ -0,0 +1,4 @@
package providers
//go:generate mockgen -package mock -destination ./mock/provider.mock.go github.com/caos/zitadel/internal/notification/providers NotificationProvider
//go:generate mockgen -package mock -destination ./mock/message.mock.go github.com/caos/zitadel/internal/notification/providers Message

View File

@ -0,0 +1,5 @@
package providers
type Message interface {
GetContent() string
}

View File

@ -0,0 +1,47 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/caos/zitadel/internal/notification/providers (interfaces: Message)
// Package mock is a generated GoMock package.
package mock
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockMessage is a mock of Message interface
type MockMessage struct {
ctrl *gomock.Controller
recorder *MockMessageMockRecorder
}
// MockMessageMockRecorder is the mock recorder for MockMessage
type MockMessageMockRecorder struct {
mock *MockMessage
}
// NewMockMessage creates a new mock instance
func NewMockMessage(ctrl *gomock.Controller) *MockMessage {
mock := &MockMessage{ctrl: ctrl}
mock.recorder = &MockMessageMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockMessage) EXPECT() *MockMessageMockRecorder {
return m.recorder
}
// GetContent mocks base method
func (m *MockMessage) GetContent() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetContent")
ret0, _ := ret[0].(string)
return ret0
}
// GetContent indicates an expected call of GetContent
func (mr *MockMessageMockRecorder) GetContent() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockMessage)(nil).GetContent))
}

View File

@ -0,0 +1,61 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/caos/zitadel/internal/notification/providers (interfaces: NotificationProvider)
// Package mock is a generated GoMock package.
package mock
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockNotificationProvider is a mock of NotificationProvider interface
type MockNotificationProvider struct {
ctrl *gomock.Controller
recorder *MockNotificationProviderMockRecorder
}
// MockNotificationProviderMockRecorder is the mock recorder for MockNotificationProvider
type MockNotificationProviderMockRecorder struct {
mock *MockNotificationProvider
}
// NewMockNotificationProvider creates a new mock instance
func NewMockNotificationProvider(ctrl *gomock.Controller) *MockNotificationProvider {
mock := &MockNotificationProvider{ctrl: ctrl}
mock.recorder = &MockNotificationProviderMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockNotificationProvider) EXPECT() *MockNotificationProviderMockRecorder {
return m.recorder
}
// CanHandleMessage mocks base method
func (m *MockNotificationProvider) CanHandleMessage() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CanHandleMessage")
ret0, _ := ret[0].(bool)
return ret0
}
// CanHandleMessage indicates an expected call of CanHandleMessage
func (mr *MockNotificationProviderMockRecorder) CanHandleMessage() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanHandleMessage", reflect.TypeOf((*MockNotificationProvider)(nil).CanHandleMessage))
}
// HandleMessage mocks base method
func (m *MockNotificationProvider) HandleMessage() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HandleMessage")
ret0, _ := ret[0].(error)
return ret0
}
// HandleMessage indicates an expected call of HandleMessage
func (mr *MockNotificationProviderMockRecorder) HandleMessage() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleMessage", reflect.TypeOf((*MockNotificationProvider)(nil).HandleMessage))
}

View File

@ -0,0 +1,6 @@
package providers
type NotificationProvider interface {
CanHandleMessage() bool
HandleMessage() error
}

View File

@ -0,0 +1,7 @@
package twilio
type TwilioConfig struct {
SID string
Token string
From string
}

View File

@ -0,0 +1,11 @@
package twilio
type TwilioMessage struct {
SenderPhoneNumber string
RecipientPhoneNumber string
Content string
}
func (msg *TwilioMessage) GetContent() string {
return msg.Content
}

View File

@ -0,0 +1,39 @@
package twilio
import (
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
twilio "github.com/kevinburke/twilio-go"
)
type Twilio struct {
client *twilio.Client
}
func InitTwilioProvider(config TwilioConfig) *Twilio {
return &Twilio{
client: twilio.NewClient(config.SID, config.Token, nil),
}
}
func (t *Twilio) CanHandleMessage(message providers.Message) bool {
twilioMsg, ok := message.(*TwilioMessage)
if !ok {
return false
}
return twilioMsg.Content != "" && twilioMsg.RecipientPhoneNumber != "" && twilioMsg.SenderPhoneNumber != ""
}
func (t *Twilio) HandleMessage(message providers.Message) error {
twilioMsg, ok := message.(*TwilioMessage)
if !ok {
return caos_errs.ThrowInternal(nil, "TWILI-s0pLc", "message is not TwilioMessage")
}
m, err := t.client.Messages.SendMessage(twilioMsg.SenderPhoneNumber, twilioMsg.RecipientPhoneNumber, twilioMsg.GetContent(), nil)
if err != nil {
return caos_errs.ThrowInternal(err, "TWILI-osk3S", "could not send message")
}
logging.LogWithFields("SMS_-f335c523", "message_sid", m.Sid, "status", m.Status).Debug("sms sent")
return nil
}

View File

@ -0,0 +1,49 @@
package handler
import (
"github.com/caos/logging"
sd "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/config/types"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/view"
usr_event "github.com/caos/zitadel/internal/user/repository/eventsourcing"
"time"
)
type Configs map[string]*Config
type Config struct {
MinimumCycleDuration types.Duration
}
type handler struct {
view *view.View
bulkLimit uint64
cycleDuration time.Duration
errorCountUntilSkip uint64
}
type EventstoreRepos struct {
UserEvents *usr_event.UserEventstore
}
func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, eventstore eventstore.Eventstore, repos EventstoreRepos, systemDefaults sd.SystemDefaults) []spooler.Handler {
aesCrypto, err := crypto.NewAESCrypto(systemDefaults.UserVerificationKey)
if err != nil {
logging.Log("HANDL-s90ew").WithError(err).Debug("error create new aes crypto")
}
return []spooler.Handler{
&NotifyUser{handler: handler{view, bulkLimit, configs.cycleDuration("User"), errorCount}},
&Notification{handler: handler{view, bulkLimit, configs.cycleDuration("Notification"), errorCount}, eventstore: eventstore, userEvents: repos.UserEvents, systemDefaults: systemDefaults, AesCrypto: aesCrypto},
}
}
func (configs Configs) cycleDuration(viewModel string) time.Duration {
c, ok := configs[viewModel]
if !ok {
return 1 * time.Second
}
return c.MinimumCycleDuration.Duration
}

View File

@ -0,0 +1,168 @@
package handler
import (
"context"
"github.com/caos/zitadel/internal/api/auth"
sd "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/notification/types"
usr_event "github.com/caos/zitadel/internal/user/repository/eventsourcing"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"time"
"github.com/caos/logging"
"github.com/caos/zitadel/internal/eventstore/models"
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/user/repository/eventsourcing"
)
type Notification struct {
handler
eventstore eventstore.Eventstore
userEvents *usr_event.UserEventstore
systemDefaults sd.SystemDefaults
AesCrypto crypto.EncryptionAlgorithm
}
const (
notificationTable = "notification.notifications"
NOTIFY_USER = "NOTIFICATION"
)
func (n *Notification) MinimumCycleDuration() time.Duration { return n.cycleDuration }
func (n *Notification) ViewModel() string {
return notificationTable
}
func (n *Notification) EventQuery() (*models.SearchQuery, error) {
sequence, err := n.view.GetLatestNotificationSequence()
if err != nil {
return nil, err
}
return eventsourcing.UserQuery(sequence), nil
}
func (n *Notification) Process(event *models.Event) (err error) {
switch event.Type {
case es_model.InitializedUserCodeAdded:
err = n.handleInitUserCode(event)
case es_model.UserEmailCodeAdded:
err = n.handleEmailVerificationCode(event)
case es_model.UserPhoneCodeAdded:
err = n.handlePhoneVerificationCode(event)
case es_model.UserPasswordCodeAdded:
err = n.handlePasswordCode(event)
default:
return n.view.ProcessedNotificationSequence(event.Sequence)
}
if err != nil {
return err
}
return n.view.ProcessedNotificationSequence(event.Sequence)
}
func (n *Notification) handleInitUserCode(event *models.Event) (err error) {
alreadyHandled, err := n.checkIfCodeAlreadyHandled(event.AggregateID, event.Sequence, es_model.InitializedUserCodeAdded, es_model.InitializedUserCodeSent)
if err != nil || alreadyHandled {
return err
}
initCode := new(es_model.InitUserCode)
initCode.SetData(event)
user, err := n.view.NotifyUserByID(event.AggregateID)
if err != nil {
return err
}
err = types.SendUserInitCode(user, initCode, n.systemDefaults, n.AesCrypto)
if err != nil {
return err
}
return n.userEvents.InitCodeSent(getSetNotifyContextData(event.ResourceOwner), event.AggregateID)
}
func (n *Notification) handlePasswordCode(event *models.Event) (err error) {
alreadyHandled, err := n.checkIfCodeAlreadyHandled(event.AggregateID, event.Sequence, es_model.UserPasswordCodeAdded, es_model.UserPasswordCodeSent)
if err != nil || alreadyHandled {
return err
}
pwCode := new(es_model.PasswordCode)
pwCode.SetData(event)
user, err := n.view.NotifyUserByID(event.AggregateID)
if err != nil {
return err
}
err = types.SendPasswordCode(user, pwCode, n.systemDefaults, n.AesCrypto)
if err != nil {
return err
}
return n.userEvents.PasswordCodeSent(getSetNotifyContextData(event.ResourceOwner), event.AggregateID)
}
func (n *Notification) handleEmailVerificationCode(event *models.Event) (err error) {
alreadyHandled, err := n.checkIfCodeAlreadyHandled(event.AggregateID, event.Sequence, es_model.UserEmailCodeAdded, es_model.UserEmailCodeSent)
if err != nil || alreadyHandled {
return nil
}
emailCode := new(es_model.EmailCode)
emailCode.SetData(event)
user, err := n.view.NotifyUserByID(event.AggregateID)
if err != nil {
return err
}
err = types.SendEmailVerificationCode(user, emailCode, n.systemDefaults, n.AesCrypto)
if err != nil {
return err
}
return n.userEvents.EmailVerificationCodeSent(getSetNotifyContextData(event.ResourceOwner), event.AggregateID)
}
func (n *Notification) handlePhoneVerificationCode(event *models.Event) (err error) {
alreadyHandled, err := n.checkIfCodeAlreadyHandled(event.AggregateID, event.Sequence, es_model.UserPhoneCodeAdded, es_model.UserPhoneCodeSent)
if err != nil || alreadyHandled {
return nil
}
phoneCode := new(es_model.PhoneCode)
phoneCode.SetData(event)
user, err := n.view.NotifyUserByID(event.AggregateID)
if err != nil {
return err
}
err = types.SendPhoneVerificationCode(user, phoneCode, n.systemDefaults, n.AesCrypto)
if err != nil {
return err
}
return n.userEvents.PhoneVerificationCodeSent(getSetNotifyContextData(event.ResourceOwner), event.AggregateID)
}
func (n *Notification) checkIfCodeAlreadyHandled(userID string, sequence uint64, addedType, sentType models.EventType) (bool, error) {
events, err := n.getUserEvents(userID, sequence)
if err != nil {
return false, err
}
for _, event := range events {
if event.Type == addedType || event.Type == sentType {
return true, nil
}
}
return false, nil
}
func (n *Notification) getUserEvents(userID string, sequence uint64) ([]*models.Event, error) {
query, err := eventsourcing.UserByIDQuery(userID, sequence)
if err != nil {
return nil, err
}
return n.eventstore.FilterEvents(context.Background(), query)
}
func (n *Notification) OnError(event *models.Event, err error) error {
logging.LogWithFields("SPOOL-s9opc", "id", event.AggregateID, "sequence", event.Sequence).WithError(err).Warn("something went wrong in notification handler")
return spooler.HandleError(event, err, n.view.GetLatestNotificationFailedEvent, n.view.ProcessedNotificationFailedEvent, n.view.ProcessedNotificationSequence, n.errorCountUntilSkip)
}
func getSetNotifyContextData(orgID string) context.Context {
return auth.SetCtxData(context.Background(), auth.CtxData{UserID: NOTIFY_USER, OrgID: orgID})
}

View File

@ -0,0 +1,69 @@
package handler
import (
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"time"
"github.com/caos/logging"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/eventstore/models"
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/user/repository/eventsourcing"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
type NotifyUser struct {
handler
eventstore eventstore.Eventstore
}
const (
userTable = "notification.notify_users"
)
func (p *NotifyUser) MinimumCycleDuration() time.Duration { return p.cycleDuration }
func (p *NotifyUser) ViewModel() string {
return userTable
}
func (p *NotifyUser) EventQuery() (*models.SearchQuery, error) {
sequence, err := p.view.GetLatestNotifyUserSequence()
if err != nil {
return nil, err
}
return eventsourcing.UserQuery(sequence), nil
}
func (p *NotifyUser) Process(event *models.Event) (err error) {
user := new(view_model.NotifyUser)
switch event.Type {
case es_model.UserAdded,
es_model.UserRegistered:
user.AppendEvent(event)
case es_model.UserProfileChanged,
es_model.UserEmailChanged,
es_model.UserEmailVerified,
es_model.UserPhoneChanged,
es_model.UserPhoneVerified:
user, err = p.view.NotifyUserByID(event.AggregateID)
if err != nil {
return err
}
err = user.AppendEvent(event)
case es_model.UserDeleted:
err = p.view.DeleteNotifyUser(event.AggregateID, event.Sequence)
default:
return p.view.ProcessedNotifyUserSequence(event.Sequence)
}
if err != nil {
return err
}
return p.view.PutNotifyUser(user)
}
func (p *NotifyUser) OnError(event *models.Event, err error) error {
logging.LogWithFields("SPOOL-9spwf", "id", event.AggregateID).WithError(err).Warn("something went wrong in notify user handler")
return spooler.HandleError(event, err, p.view.GetLatestNotifyUserFailedEvent, p.view.ProcessedNotifyUserFailedEvent, p.view.ProcessedNotifyUserSequence, p.errorCountUntilSkip)
}

View File

@ -0,0 +1,56 @@
package eventsourcing
import (
sd "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/config/types"
es_int "github.com/caos/zitadel/internal/eventstore"
es_spol "github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/handler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/spooler"
noti_view "github.com/caos/zitadel/internal/notification/repository/eventsourcing/view"
es_usr "github.com/caos/zitadel/internal/user/repository/eventsourcing"
)
type Config struct {
Eventstore es_int.Config
View types.SQL
Spooler spooler.SpoolerConfig
}
type EsRepository struct {
spooler *es_spol.Spooler
}
func Start(conf Config, systemDefaults sd.SystemDefaults) (*EsRepository, error) {
es, err := es_int.Start(conf.Eventstore)
if err != nil {
return nil, err
}
sqlClient, err := conf.View.Start()
if err != nil {
return nil, err
}
view, err := noti_view.StartView(sqlClient)
if err != nil {
return nil, err
}
user, err := es_usr.StartUser(es_usr.UserConfig{
Eventstore: es,
Cache: conf.Eventstore.Cache,
}, systemDefaults)
if err != nil {
return nil, err
}
eventstoreRepos := handler.EventstoreRepos{UserEvents: user}
spool := spooler.StartSpooler(conf.Spooler, es, view, sqlClient, eventstoreRepos, systemDefaults)
return &EsRepository{
spool,
}, nil
}
func (repo *EsRepository) Health() error {
return nil
}

View File

@ -0,0 +1,19 @@
package spooler
import (
"database/sql"
es_locker "github.com/caos/zitadel/internal/eventstore/locker"
"time"
)
const (
lockTable = "notification.locks"
)
type locker struct {
dbClient *sql.DB
}
func (l *locker) Renew(lockerID, viewModel string, waitTime time.Duration) error {
return es_locker.Renew(l.dbClient, lockTable, lockerID, viewModel, waitTime)
}

View File

@ -0,0 +1,127 @@
package spooler
import (
"database/sql"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
type dbMock struct {
db *sql.DB
mock sqlmock.Sqlmock
}
func mockDB(t *testing.T) *dbMock {
mockDB := dbMock{}
var err error
mockDB.db, mockDB.mock, err = sqlmock.New()
if err != nil {
t.Fatalf("error occured while creating stub db %v", err)
}
mockDB.mock.MatchExpectationsInOrder(true)
return &mockDB
}
func (db *dbMock) expectCommit() *dbMock {
db.mock.ExpectCommit()
return db
}
func (db *dbMock) expectRollback() *dbMock {
db.mock.ExpectRollback()
return db
}
func (db *dbMock) expectBegin() *dbMock {
db.mock.ExpectBegin()
return db
}
func (db *dbMock) expectSavepoint() *dbMock {
db.mock.ExpectExec("SAVEPOINT").WillReturnResult(sqlmock.NewResult(1, 1))
return db
}
func (db *dbMock) expectReleaseSavepoint() *dbMock {
db.mock.ExpectExec("RELEASE SAVEPOINT").WillReturnResult(sqlmock.NewResult(1, 1))
return db
}
func (db *dbMock) expectRenew(lockerID, view string, affectedRows int64) *dbMock {
query := db.mock.
ExpectExec(`INSERT INTO notification\.locks \(object_type, locker_id, locked_until\) VALUES \(\$1, \$2, now\(\)\+\$3\) ON CONFLICT \(object_type\) DO UPDATE SET locked_until = now\(\)\+\$4, locker_id = \$5 WHERE \(locks\.locked_until < now\(\) OR locks\.locker_id = \$6\) AND locks\.object_type = \$7`).
WithArgs(view, lockerID, sqlmock.AnyArg(), sqlmock.AnyArg(), lockerID, lockerID, view).
WillReturnResult(sqlmock.NewResult(1, 1))
if affectedRows == 0 {
query.WillReturnResult(sqlmock.NewResult(0, 0))
} else {
query.WillReturnResult(sqlmock.NewResult(1, affectedRows))
}
return db
}
func Test_locker_Renew(t *testing.T) {
type fields struct {
db *dbMock
}
type args struct {
lockerID string
viewModel string
waitTime time.Duration
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{
name: "renew succeeded",
fields: fields{
db: mockDB(t).
expectBegin().
expectSavepoint().
expectRenew("locker", "view", 1).
expectReleaseSavepoint().
expectCommit(),
},
args: args{lockerID: "locker", viewModel: "view", waitTime: 1 * time.Second},
wantErr: false,
},
{
name: "renew now rows updated",
fields: fields{
db: mockDB(t).
expectBegin().
expectSavepoint().
expectRenew("locker", "view", 0).
expectRollback(),
},
args: args{lockerID: "locker", viewModel: "view", waitTime: 1 * time.Second},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &locker{
dbClient: tt.fields.db.db,
}
if err := l.Renew(tt.args.lockerID, tt.args.viewModel, tt.args.waitTime); (err != nil) != tt.wantErr {
t.Errorf("locker.Renew() error = %v, wantErr %v", err, tt.wantErr)
}
if err := tt.fields.db.mock.ExpectationsWereMet(); err != nil {
t.Errorf("not all database expectations met: %v", err)
}
})
}
}

View File

@ -0,0 +1,34 @@
package spooler
import (
"database/sql"
sd "github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/handler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/view"
usr_event "github.com/caos/zitadel/internal/user/repository/eventsourcing"
)
type SpoolerConfig struct {
BulkLimit uint64
FailureCountUntilSkip uint64
ConcurrentTasks int
Handlers handler.Configs
}
type EventstoreRepos struct {
UserEvents *usr_event.UserEventstore
}
func StartSpooler(c SpoolerConfig, es eventstore.Eventstore, view *view.View, sql *sql.DB, eventstoreRepos handler.EventstoreRepos, systemDefaults sd.SystemDefaults) *spooler.Spooler {
spoolerConfig := spooler.Config{
Eventstore: es,
Locker: &locker{dbClient: sql},
ConcurrentTasks: c.ConcurrentTasks,
ViewHandlers: handler.Register(c.Handlers, c.BulkLimit, c.FailureCountUntilSkip, view, es, eventstoreRepos, systemDefaults),
}
spool := spoolerConfig.New()
spool.Start()
return spool
}

View File

@ -0,0 +1,17 @@
package view
import (
"github.com/caos/zitadel/internal/view"
)
const (
errTable = "notification.failed_event"
)
func (v *View) saveFailedEvent(failedEvent *view.FailedEvent) error {
return view.SaveFailedEvent(v.Db, errTable, failedEvent)
}
func (v *View) latestFailedEvent(viewName string, sequence uint64) (*view.FailedEvent, error) {
return view.LatestFailedEvent(v.Db, errTable, viewName, sequence)
}

View File

@ -0,0 +1,25 @@
package view
import (
global_view "github.com/caos/zitadel/internal/view"
)
const (
notificationTable = "notification.notifications"
)
func (v *View) GetLatestNotificationSequence() (uint64, error) {
return v.latestSequence(notificationTable)
}
func (v *View) ProcessedNotificationSequence(eventSequence uint64) error {
return v.saveCurrentSequence(notificationTable, eventSequence)
}
func (v *View) GetLatestNotificationFailedEvent(sequence uint64) (*global_view.FailedEvent, error) {
return v.latestFailedEvent(notificationTable, sequence)
}
func (v *View) ProcessedNotificationFailedEvent(failedEvent *global_view.FailedEvent) error {
return v.saveFailedEvent(failedEvent)
}

View File

@ -0,0 +1,47 @@
package view
import (
"github.com/caos/zitadel/internal/user/repository/view"
"github.com/caos/zitadel/internal/user/repository/view/model"
global_view "github.com/caos/zitadel/internal/view"
)
const (
notifyUserTable = "notification.notify_users"
)
func (v *View) NotifyUserByID(userID string) (*model.NotifyUser, error) {
return view.NotifyUserByID(v.Db, notifyUserTable, userID)
}
func (v *View) PutNotifyUser(user *model.NotifyUser) error {
err := view.PutNotifyUser(v.Db, notifyUserTable, user)
if err != nil {
return err
}
return v.ProcessedNotifyUserSequence(user.Sequence)
}
func (v *View) DeleteNotifyUser(userID string, eventSequence uint64) error {
err := view.DeleteNotifyUser(v.Db, notifyUserTable, userID)
if err != nil {
return nil
}
return v.ProcessedNotifyUserSequence(eventSequence)
}
func (v *View) GetLatestNotifyUserSequence() (uint64, error) {
return v.latestSequence(notifyUserTable)
}
func (v *View) ProcessedNotifyUserSequence(eventSequence uint64) error {
return v.saveCurrentSequence(notifyUserTable, eventSequence)
}
func (v *View) GetLatestNotifyUserFailedEvent(sequence uint64) (*global_view.FailedEvent, error) {
return v.latestFailedEvent(notifyUserTable, sequence)
}
func (v *View) ProcessedNotifyUserFailedEvent(failedEvent *global_view.FailedEvent) error {
return v.saveFailedEvent(failedEvent)
}

View File

@ -0,0 +1,17 @@
package view
import (
"github.com/caos/zitadel/internal/view"
)
const (
sequencesTable = "notification.current_sequences"
)
func (v *View) saveCurrentSequence(viewName string, sequence uint64) error {
return view.SaveCurrentSequence(v.Db, sequencesTable, viewName, sequence)
}
func (v *View) latestSequence(viewName string) (uint64, error) {
return view.LatestSequence(v.Db, sequencesTable, viewName)
}

View File

@ -0,0 +1,25 @@
package view
import (
"database/sql"
"github.com/jinzhu/gorm"
)
type View struct {
Db *gorm.DB
}
func StartView(sqlClient *sql.DB) (*View, error) {
gorm, err := gorm.Open("postgres", sqlClient)
if err != nil {
return nil, err
}
return &View{
Db: gorm,
}, nil
}
func (v *View) Health() (err error) {
return v.Db.DB().Ping()
}

View File

@ -0,0 +1,5 @@
package repository
type Repository interface {
Health() error
}

View File

@ -0,0 +1,45 @@
package templates
import (
"bytes"
"html/template"
)
const (
templateFileName = "template.html"
)
func GetParsedTemplate(contentData interface{}) (string, error) {
template, err := ParseTemplateFile("", contentData)
if err != nil {
return "", err
}
return ParseTemplateText(template, contentData)
}
func ParseTemplateFile(fileName string, data interface{}) (string, error) {
if fileName == "" {
fileName = templateFileName
}
template, err := template.ParseFiles(fileName)
if err != nil {
return "", err
}
return parseTemplate(template, data)
}
func ParseTemplateText(text string, data interface{}) (string, error) {
template, err := template.New("template").Parse(text)
if err != nil {
return "", err
}
return parseTemplate(template, data)
}
func parseTemplate(template *template.Template, data interface{}) (string, error) {
buf := new(bytes.Buffer)
if err := template.Execute(buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@ -0,0 +1,11 @@
package templates
type TemplateData struct {
Title string
PreHeader string
Subject string
Greeting string
Text string
Href string
ButtonText string
}

View File

@ -0,0 +1,34 @@
package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/notification/templates"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
type EmailVerificationCodeData struct {
templates.TemplateData
FirstName string
LastName string
URL string
}
func SendEmailVerificationCode(user *view_model.NotifyUser, code *es_model.EmailCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm) error {
codeString, err := crypto.DecryptString(code.Code, alg)
if err != nil {
return err
}
url, err := templates.ParseTemplateText(systemDefaults.Notifications.Endpoints.VerifyEmail, &UrlData{UserID: user.ID, Code: codeString})
if err != nil {
return err
}
emailCodeData := &EmailVerificationCodeData{TemplateData: systemDefaults.Notifications.TemplateData.VerifyEmail, FirstName: user.FirstName, LastName: user.LastName, URL: url}
template, err := templates.GetParsedTemplate(emailCodeData)
if err != nil {
return err
}
return generateEmail(user, template, systemDefaults.Notifications, true)
}

View File

@ -0,0 +1,39 @@
package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/notification/templates"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
type InitCodeEmailData struct {
templates.TemplateData
FirstName string
LastName string
URL string
}
type UrlData struct {
UserID string
Code string
}
func SendUserInitCode(user *view_model.NotifyUser, code *es_model.InitUserCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm) error {
codeString, err := crypto.DecryptString(code.Code, alg)
if err != nil {
return err
}
url, err := templates.ParseTemplateText(systemDefaults.Notifications.Endpoints.InitCode, &UrlData{UserID: user.ID, Code: codeString})
if err != nil {
return err
}
initCodeData := &InitCodeEmailData{TemplateData: systemDefaults.Notifications.TemplateData.InitCode, FirstName: user.FirstName, LastName: user.LastName, URL: url}
template, err := templates.GetParsedTemplate(initCodeData)
if err != nil {
return err
}
return generateEmail(user, template, systemDefaults.Notifications, true)
}

View File

@ -0,0 +1,34 @@
package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/notification/templates"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
type PasswordCodeData struct {
templates.TemplateData
FirstName string
LastName string
URL string
}
func SendPasswordCode(user *view_model.NotifyUser, code *es_model.PasswordCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm) error {
codeString, err := crypto.DecryptString(code.Code, alg)
if err != nil {
return err
}
url, err := templates.ParseTemplateText(systemDefaults.Notifications.Endpoints.PasswordReset, &UrlData{UserID: user.ID, Code: codeString})
if err != nil {
return err
}
passwordCodeData := &PasswordCodeData{TemplateData: systemDefaults.Notifications.TemplateData.PasswordReset, FirstName: user.FirstName, LastName: user.LastName, URL: url}
template, err := templates.GetParsedTemplate(passwordCodeData)
if err != nil {
return err
}
return generateEmail(user, template, systemDefaults.Notifications, false)
}

View File

@ -0,0 +1,29 @@
package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
"github.com/caos/zitadel/internal/crypto"
"github.com/caos/zitadel/internal/notification/templates"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
type PhoneVerificationCodeData struct {
FirstName string
LastName string
Code string
UserID string
}
func SendPhoneVerificationCode(user *view_model.NotifyUser, code *es_model.PhoneCode, systemDefaults systemdefaults.SystemDefaults, alg crypto.EncryptionAlgorithm) error {
codeString, err := crypto.DecryptString(code.Code, alg)
if err != nil {
return err
}
codeData := &PhoneVerificationCodeData{FirstName: user.FirstName, LastName: user.LastName, UserID: user.ID, Code: codeString}
template, err := templates.ParseTemplateText(systemDefaults.Notifications.TemplateData.VerifyPhone.Text, codeData)
if err != nil {
return err
}
return generateSms(user, template, systemDefaults.Notifications, true)
}

View File

@ -0,0 +1,41 @@
package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"github.com/caos/zitadel/internal/notification/providers/chat"
"github.com/caos/zitadel/internal/notification/providers/email"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
func generateEmail(user *view_model.NotifyUser, content string, config systemdefaults.Notifications, lastEmail bool) error {
provider, err := email.InitEmailProvider(config.Providers.Email)
if err != nil {
return err
}
message := &email.EmailMessage{
SenderEmail: config.Providers.Email.From,
Recipients: []string{user.VerifiedEmail},
Subject: config.TemplateData.InitCode.Subject,
Content: content,
}
if lastEmail {
message.Recipients = []string{user.LastEmail}
}
if provider.CanHandleMessage(message) {
if config.DebugMode {
return sendDebugEmail(message, config)
}
return provider.HandleMessage(message)
}
return caos_errs.ThrowInternalf(nil, "NOTIF-s8ipw", "Could not send init message: userid: %v", user.ID)
}
func sendDebugEmail(message providers.Message, config systemdefaults.Notifications) error {
provider, err := chat.InitChatProvider(config.Providers.Chat)
if err != nil {
return err
}
return provider.HandleMessage(message)
}

View File

@ -0,0 +1,37 @@
package types
import (
"github.com/caos/zitadel/internal/config/systemdefaults"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/notification/providers"
"github.com/caos/zitadel/internal/notification/providers/chat"
"github.com/caos/zitadel/internal/notification/providers/twilio"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
func generateSms(user *view_model.NotifyUser, content string, config systemdefaults.Notifications, lastPhone bool) error {
provider := twilio.InitTwilioProvider(config.Providers.Twilio)
message := &twilio.TwilioMessage{
SenderPhoneNumber: config.Providers.Twilio.From,
RecipientPhoneNumber: user.VerifiedPhone,
Content: content,
}
if lastPhone {
message.RecipientPhoneNumber = user.LastPhone
}
if provider.CanHandleMessage(message) {
if config.DebugMode {
return sendDebugPhone(message, config)
}
return provider.HandleMessage(message)
}
return caos_errs.ThrowInternalf(nil, "NOTIF-s8ipw", "Could not send init message: userid: %v", user.ID)
}
func sendDebugPhone(message providers.Message, config systemdefaults.Notifications) error {
provider, err := chat.InitChatProvider(config.Providers.Chat)
if err != nil {
return err
}
return provider.HandleMessage(message)
}

View File

@ -0,0 +1,53 @@
package model
import (
"github.com/caos/zitadel/internal/model"
"time"
)
type NotifyUser struct {
ID string
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
UserName string
FirstName string
LastName string
NickName string
DisplayName string
PreferredLanguage string
Gender Gender
LastEmail string
VerifiedEmail string
LastPhone string
VerifiedPhone string
Sequence uint64
}
type NotifyUserSearchRequest struct {
Offset uint64
Limit uint64
SortingColumn NotifyUserSearchKey
Asc bool
Queries []*NotifyUserSearchQuery
}
type NotifyUserSearchKey int32
const (
NOTIFYUSERSEARCHKEY_UNSPECIFIED UserSearchKey = iota
NOTIFYUSERSEARCHKEY_USER_ID
)
type NotifyUserSearchQuery struct {
Key NotifyUserSearchKey
Method model.SearchMethod
Value string
}
type NotifyUserSearchResponse struct {
Offset uint64
Limit uint64
TotalResult uint64
Result []*UserView
}

View File

@ -1,13 +1,18 @@
package model
import (
"encoding/json"
"github.com/caos/logging"
"github.com/caos/zitadel/internal/crypto"
caos_errs "github.com/caos/zitadel/internal/errors"
es_models "github.com/caos/zitadel/internal/eventstore/models"
"github.com/ttacon/libphonenumber"
"time"
)
const (
//TODO: How do we get region?
defaultRegion = "CH"
)
type Phone struct {
es_models.ObjectRoot
@ -23,27 +28,16 @@ type PhoneCode struct {
}
func (p *Phone) IsValid() bool {
return p.PhoneNumber != ""
err := p.formatPhone()
return p.PhoneNumber != "" && err == nil
}
func (u *User) appendUserPhoneChangedEvent(event *es_models.Event) error {
u.Phone = new(Phone)
u.Phone.setData(event)
u.IsPhoneVerified = false
return nil
}
func (u *User) appendUserPhoneVerifiedEvent() error {
u.IsPhoneVerified = true
return nil
}
func (p *Phone) setData(event *es_models.Event) error {
p.ObjectRoot.AppendEvent(event)
if err := json.Unmarshal(event.Data, p); err != nil {
logging.Log("EVEN-dlo9s").WithError(err).Error("could not unmarshal event data")
return err
func (p *Phone) formatPhone() error {
phoneNr, err := libphonenumber.Parse(p.PhoneNumber, defaultRegion)
if err != nil {
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-so0wa", "Phonenumber is invalid")
}
p.PhoneNumber = libphonenumber.Format(phoneNr, libphonenumber.E164)
return nil
}

View File

@ -0,0 +1,106 @@
package model
import (
caos_errs "github.com/caos/zitadel/internal/errors"
"testing"
)
func TestFormatPhoneNumber(t *testing.T) {
type args struct {
phone *Phone
}
tests := []struct {
name string
args args
result *Phone
errFunc func(err error) bool
}{
{
name: "invalid phone number",
args: args{
phone: &Phone{
PhoneNumber: "PhoneNumber",
},
},
errFunc: caos_errs.IsPreconditionFailed,
},
{
name: "format phone 071...",
args: args{
phone: &Phone{
PhoneNumber: "0711234567",
},
},
result: &Phone{
PhoneNumber: "+41711234567",
},
},
{
name: "format phone 0041...",
args: args{
phone: &Phone{
PhoneNumber: "0041711234567",
},
},
result: &Phone{
PhoneNumber: "+41711234567",
},
},
{
name: "format phone 071 xxx xx xx",
args: args{
phone: &Phone{
PhoneNumber: "071 123 45 67",
},
},
result: &Phone{
PhoneNumber: "+41711234567",
},
},
{
name: "format phone +4171 xxx xx xx",
args: args{
phone: &Phone{
PhoneNumber: "+4171 123 45 67",
},
},
result: &Phone{
PhoneNumber: "+41711234567",
},
},
{
name: "format phone 004171 xxx xx xx",
args: args{
phone: &Phone{
PhoneNumber: "004171 123 45 67",
},
},
result: &Phone{
PhoneNumber: "+41711234567",
},
},
{
name: "format non swiss phone 004371 xxx xx xx",
args: args{
phone: &Phone{
PhoneNumber: "004371 123 45 67",
},
},
result: &Phone{
PhoneNumber: "+43711234567",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.args.phone.formatPhone()
if tt.errFunc == nil && tt.result.PhoneNumber != tt.args.phone.PhoneNumber {
t.Errorf("got wrong result: expected: %v, actual: %v ", tt.args.phone.PhoneNumber, tt.result.PhoneNumber)
}
if tt.errFunc != nil && !tt.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}

View File

@ -58,7 +58,7 @@ func (u *User) SetEmailAsUsername() {
}
func (u *User) IsValid() bool {
return u.Profile != nil && u.FirstName != "" && u.LastName != "" && u.UserName != "" && u.Email != nil && u.EmailAddress != ""
return u.Profile != nil && u.FirstName != "" && u.LastName != "" && u.UserName != "" && u.Email != nil && u.Email.IsValid() && u.Phone == nil || (u.Phone != nil && u.Phone.IsValid())
}
func (u *User) IsInitialState() bool {

View File

@ -96,7 +96,7 @@ func (es *UserEventstore) UserByID(ctx context.Context, id string) (*usr_model.U
func (es *UserEventstore) PrepareCreateUser(ctx context.Context, user *usr_model.User, resourceOwner string) (*model.User, *es_models.Aggregate, error) {
user.SetEmailAsUsername()
if !user.IsValid() {
return nil, nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-9dk45", "Name is required")
return nil, nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-9dk45", "User is invalid")
}
//TODO: Check Uniqueness
id, err := es.idGenerator.NextID()
@ -303,6 +303,25 @@ func (es *UserEventstore) CreateInitializeUserCodeByID(ctx context.Context, user
return model.InitCodeToModel(repoUser.InitCode), nil
}
func (es *UserEventstore) InitCodeSent(ctx context.Context, userID string) error {
if userID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-0posw", "userID missing")
}
user, err := es.UserByID(ctx, userID)
if err != nil {
return err
}
repoUser := model.UserFromModel(user)
agg := UserInitCodeSentAggregate(es.AggregateCreator(), repoUser)
err = es_sdk.Push(ctx, es.PushAggregates, repoUser.AppendEvents, agg)
if err != nil {
return err
}
es.userCache.cacheUser(repoUser)
return nil
}
func (es *UserEventstore) SkipMfaInit(ctx context.Context, userID string) error {
if userID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-dic8s", "userID missing")
@ -446,6 +465,25 @@ func (es *UserEventstore) RequestSetPassword(ctx context.Context, userID string,
return nil
}
func (es *UserEventstore) PasswordCodeSent(ctx context.Context, userID string) error {
if userID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-s09ow", "userID missing")
}
user, err := es.UserByID(ctx, userID)
if err != nil {
return err
}
repoUser := model.UserFromModel(user)
agg := PasswordCodeSentAggregate(es.AggregateCreator(), repoUser)
err = es_sdk.Push(ctx, es.PushAggregates, repoUser.AppendEvents, agg)
if err != nil {
return err
}
es.userCache.cacheUser(repoUser)
return nil
}
func (es *UserEventstore) ProfileByID(ctx context.Context, userID string) (*usr_model.Profile, error) {
if userID == "" {
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-di834", "userID missing")
@ -584,6 +622,25 @@ func (es *UserEventstore) CreateEmailVerificationCode(ctx context.Context, userI
return nil
}
func (es *UserEventstore) EmailVerificationCodeSent(ctx context.Context, userID string) error {
if userID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-spo0w", "userID missing")
}
user, err := es.UserByID(ctx, userID)
if err != nil {
return err
}
repoUser := model.UserFromModel(user)
agg := EmailCodeSentAggregate(es.AggregateCreator(), repoUser)
err = es_sdk.Push(ctx, es.PushAggregates, repoUser.AppendEvents, agg)
if err != nil {
return err
}
es.userCache.cacheUser(repoUser)
return nil
}
func (es *UserEventstore) PhoneByID(ctx context.Context, userID string) (*usr_model.Phone, error) {
if userID == "" {
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-do9se", "userID missing")
@ -686,6 +743,25 @@ func (es *UserEventstore) CreatePhoneVerificationCode(ctx context.Context, userI
return nil
}
func (es *UserEventstore) PhoneVerificationCodeSent(ctx context.Context, userID string) error {
if userID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "EVENT-sp0wa", "userID missing")
}
user, err := es.UserByID(ctx, userID)
if err != nil {
return err
}
repoUser := model.UserFromModel(user)
agg := PhoneCodeSentAggregate(es.AggregateCreator(), repoUser)
err = es_sdk.Push(ctx, es.PushAggregates, repoUser.AppendEvents, agg)
if err != nil {
return err
}
es.userCache.cacheUser(repoUser)
return nil
}
func (es *UserEventstore) AddressByID(ctx context.Context, userID string) (*usr_model.Address, error) {
if userID == "" {
return nil, caos_errs.ThrowPreconditionFailed(nil, "EVENT-di8ws", "userID missing")

View File

@ -161,7 +161,7 @@ func TestCreateUser(t *testing.T) {
{
name: "with verified phone number",
args: args{
es: GetMockManipulateUserWithInitCodeGen(ctrl, repo_model.User{Profile: &repo_model.Profile{UserName: "EmailAddress", FirstName: "FirstName", LastName: "LastName"}, Email: &repo_model.Email{EmailAddress: "EmailAddress", IsEmailVerified: true}, Phone: &repo_model.Phone{PhoneNumber: "PhoneNumber", IsPhoneVerified: true}}),
es: GetMockManipulateUserWithInitCodeGen(ctrl, repo_model.User{Profile: &repo_model.Profile{UserName: "EmailAddress", FirstName: "FirstName", LastName: "LastName"}, Email: &repo_model.Email{EmailAddress: "EmailAddress", IsEmailVerified: true}, Phone: &repo_model.Phone{PhoneNumber: "+41711234567", IsPhoneVerified: true}}),
ctx: auth.NewMockContext("orgID", "userID"),
user: &model.User{ObjectRoot: es_models.ObjectRoot{Sequence: 1},
Profile: &model.Profile{
@ -174,7 +174,7 @@ func TestCreateUser(t *testing.T) {
IsEmailVerified: true,
},
Phone: &model.Phone{
PhoneNumber: "UserName",
PhoneNumber: "+41711234567",
IsPhoneVerified: true,
},
},
@ -191,7 +191,7 @@ func TestCreateUser(t *testing.T) {
IsEmailVerified: true,
},
Phone: &model.Phone{
PhoneNumber: "UserName",
PhoneNumber: "+41711234567",
IsPhoneVerified: true,
},
},
@ -824,6 +824,67 @@ func TestCreateInitCode(t *testing.T) {
}
}
func TestInitCodeSent(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
es *UserEventstore
ctx context.Context
existing *model.User
}
type res struct {
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "sent init",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{},
},
{
name: "empty userid",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsPreconditionFailed,
},
},
{
name: "existing user not found",
args: args{
es: GetMockManipulateUserNoEvents(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.args.es.InitCodeSent(tt.args.ctx, tt.args.existing.AggregateID)
if tt.res.errFunc == nil && err != nil {
t.Errorf("rshould not get err")
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestSkipMfaInit(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
@ -1468,6 +1529,67 @@ func TestRequestSetPassword(t *testing.T) {
}
}
func TestPasswordCodeSent(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
es *UserEventstore
ctx context.Context
existing *model.User
}
type res struct {
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "sent password code",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{},
},
{
name: "empty userid",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsPreconditionFailed,
},
},
{
name: "existing user not found",
args: args{
es: GetMockManipulateUserNoEvents(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.args.es.PasswordCodeSent(tt.args.ctx, tt.args.existing.AggregateID)
if tt.res.errFunc == nil && err != nil {
t.Errorf("rshould not get err")
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestProfileByID(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
@ -1907,6 +2029,67 @@ func TestCreateEmailVerificationCode(t *testing.T) {
}
}
func TestEmailVerificationCodeSent(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
es *UserEventstore
ctx context.Context
existing *model.User
}
type res struct {
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "sent email verify code",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{},
},
{
name: "empty userid",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsPreconditionFailed,
},
},
{
name: "existing user not found",
args: args{
es: GetMockManipulateUserNoEvents(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.args.es.EmailVerificationCodeSent(tt.args.ctx, tt.args.existing.AggregateID)
if tt.res.errFunc == nil && err != nil {
t.Errorf("rshould not get err")
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestPhoneByID(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
@ -1995,10 +2178,10 @@ func TestChangePhone(t *testing.T) {
args: args{
es: GetMockManipulateUserFull(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "PhoneNumberChanged", IsPhoneVerified: true},
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "0711234567", IsPhoneVerified: true},
},
res: res{
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "PhoneNumberChanged", IsPhoneVerified: true},
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "+41711234567", IsPhoneVerified: true},
},
},
{
@ -2006,10 +2189,10 @@ func TestChangePhone(t *testing.T) {
args: args{
es: GetMockManipulateUserWithPhoneCodeGen(ctrl, repo_model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, Profile: &repo_model.Profile{UserName: "UserName"}, Phone: &repo_model.Phone{PhoneNumber: "PhoneNumber"}}),
ctx: auth.NewMockContext("orgID", "userID"),
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "PhoneNumberChanged", IsPhoneVerified: false},
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "+41711234567", IsPhoneVerified: false},
},
res: res{
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "PhoneNumberChanged", IsPhoneVerified: false},
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "+41711234567", IsPhoneVerified: false},
},
},
{
@ -2028,7 +2211,7 @@ func TestChangePhone(t *testing.T) {
args: args{
es: GetMockManipulateUserNoEvents(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "PhoneNumberChanged", IsPhoneVerified: true},
phone: &model.Phone{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}, PhoneNumber: "+41711234567", IsPhoneVerified: true},
},
res: res{
errFunc: caos_errs.IsNotFound,
@ -2212,6 +2395,67 @@ func TestCreatePhoneVerificationCode(t *testing.T) {
}
}
func TestPhoneVerificationCodeSent(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {
es *UserEventstore
ctx context.Context
existing *model.User
}
type res struct {
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "sent phone verification code",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{},
},
{
name: "empty userid",
args: args{
es: GetMockManipulateUser(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsPreconditionFailed,
},
},
{
name: "existing user not found",
args: args{
es: GetMockManipulateUserNoEvents(ctrl),
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: es_models.ObjectRoot{AggregateID: "AggregateID", Sequence: 1}},
},
res: res{
errFunc: caos_errs.IsNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.args.es.PhoneVerificationCodeSent(tt.args.ctx, tt.args.existing.AggregateID)
if tt.res.errFunc == nil && err != nil {
t.Errorf("rshould not get err")
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestAddressByID(t *testing.T) {
ctrl := gomock.NewController(t)
type args struct {

View File

@ -76,7 +76,7 @@ func (u *User) appendUserEmailChangedEvent(event *es_models.Event) error {
func (u *User) appendUserEmailCodeAddedEvent(event *es_models.Event) error {
u.EmailCode = new(EmailCode)
return u.EmailCode.setData(event)
return u.EmailCode.SetData(event)
}
func (u *User) appendUserEmailVerifiedEvent() {
@ -92,7 +92,7 @@ func (a *Email) setData(event *es_models.Event) error {
return nil
}
func (a *EmailCode) setData(event *es_models.Event) error {
func (a *EmailCode) SetData(event *es_models.Event) error {
a.ObjectRoot.AppendEvent(event)
if err := json.Unmarshal(event.Data, a); err != nil {
logging.Log("EVEN-lo9s").WithError(err).Error("could not unmarshal event data")

View File

@ -62,7 +62,7 @@ func (u *User) appendUserPasswordChangedEvent(event *es_models.Event) error {
func (u *User) appendPasswordSetRequestedEvent(event *es_models.Event) error {
u.PasswordCode = new(PasswordCode)
return u.PasswordCode.setData(event)
return u.PasswordCode.SetData(event)
}
func (pw *Password) setData(event *es_models.Event) error {
@ -74,7 +74,7 @@ func (pw *Password) setData(event *es_models.Event) error {
return nil
}
func (a *PasswordCode) setData(event *es_models.Event) error {
func (a *PasswordCode) SetData(event *es_models.Event) error {
a.ObjectRoot.AppendEvent(event)
if err := json.Unmarshal(event.Data, a); err != nil {
logging.Log("EVEN-lo0y2").WithError(err).Error("could not unmarshal event data")

View File

@ -74,7 +74,7 @@ func (u *User) appendUserPhoneChangedEvent(event *es_models.Event) error {
func (u *User) appendUserPhoneCodeAddedEvent(event *es_models.Event) error {
u.PhoneCode = new(PhoneCode)
return u.PhoneCode.setData(event)
return u.PhoneCode.SetData(event)
}
func (u *User) appendUserPhoneVerifiedEvent() {
@ -90,7 +90,7 @@ func (p *Phone) setData(event *es_models.Event) error {
return nil
}
func (a *PhoneCode) setData(event *es_models.Event) error {
func (a *PhoneCode) SetData(event *es_models.Event) error {
a.ObjectRoot.AppendEvent(event)
if err := json.Unmarshal(event.Data, a); err != nil {
logging.Log("EVEN-sk8ws").WithError(err).Error("could not unmarshal event data")

View File

@ -228,7 +228,7 @@ func (u *User) appendUnlockedEvent() {
func (u *User) appendInitUsercodeCreatedEvent(event *es_models.Event) error {
initCode := new(InitUserCode)
err := initCode.setData(event)
err := initCode.SetData(event)
if err != nil {
return err
}
@ -237,7 +237,7 @@ func (u *User) appendInitUsercodeCreatedEvent(event *es_models.Event) error {
return nil
}
func (c *InitUserCode) setData(event *es_models.Event) error {
func (c *InitUserCode) SetData(event *es_models.Event) error {
c.ObjectRoot.AppendEvent(event)
if err := json.Unmarshal(event.Data, c); err != nil {
logging.Log("EVEN-7duwe").WithError(err).Error("could not unmarshal event data")

View File

@ -68,12 +68,6 @@ func UserCreateAggregate(ctx context.Context, aggCreator *es_models.AggregateCre
return nil, err
}
}
if user.Password != nil {
agg, err = agg.AppendEvent(model.UserPasswordCodeAdded, user.Password)
if err != nil {
return nil, err
}
}
if initCode != nil {
agg, err = agg.AppendEvent(model.InitializedUserCodeAdded, initCode)
if err != nil {
@ -145,6 +139,16 @@ func UserInitCodeAggregate(aggCreator *es_models.AggregateCreator, existing *mod
}
}
func UserInitCodeSentAggregate(aggCreator *es_models.AggregateCreator, existing *model.User) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
agg, err := UserAggregate(ctx, aggCreator, existing)
if err != nil {
return nil, err
}
return agg.AppendEvent(model.InitializedUserCodeSent, nil)
}
}
func SkipMfaAggregate(aggCreator *es_models.AggregateCreator, existing *model.User) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
agg, err := UserAggregate(ctx, aggCreator, existing)
@ -200,6 +204,16 @@ func RequestSetPassword(aggCreator *es_models.AggregateCreator, existing *model.
}
}
func PasswordCodeSentAggregate(aggCreator *es_models.AggregateCreator, existing *model.User) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
agg, err := UserAggregate(ctx, aggCreator, existing)
if err != nil {
return nil, err
}
return agg.AppendEvent(model.UserPasswordCodeSent, nil)
}
}
func ProfileChangeAggregate(aggCreator *es_models.AggregateCreator, existing *model.User, profile *model.Profile) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
if profile == nil {
@ -210,6 +224,9 @@ func ProfileChangeAggregate(aggCreator *es_models.AggregateCreator, existing *mo
return nil, err
}
changes := existing.Profile.Changes(profile)
if len(changes) == 0 {
return nil, errors.ThrowPreconditionFailed(nil, "EVENT-0spow", "no changes found")
}
return agg.AppendEvent(model.UserProfileChanged, changes)
}
}
@ -227,6 +244,9 @@ func EmailChangeAggregate(aggCreator *es_models.AggregateCreator, existing *mode
return nil, err
}
changes := existing.Email.Changes(email)
if len(changes) == 0 {
return nil, errors.ThrowPreconditionFailed(nil, "EVENT-s90pw", "no changes found")
}
agg, err = agg.AppendEvent(model.UserEmailChanged, changes)
if err != nil {
return nil, err
@ -266,6 +286,16 @@ func EmailVerificationCodeAggregate(aggCreator *es_models.AggregateCreator, exis
}
}
func EmailCodeSentAggregate(aggCreator *es_models.AggregateCreator, existing *model.User) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
agg, err := UserAggregate(ctx, aggCreator, existing)
if err != nil {
return nil, err
}
return agg.AppendEvent(model.UserEmailCodeSent, nil)
}
}
func PhoneChangeAggregate(aggCreator *es_models.AggregateCreator, existing *model.User, phone *model.Phone, code *model.PhoneCode) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
if phone == nil {
@ -282,6 +312,9 @@ func PhoneChangeAggregate(aggCreator *es_models.AggregateCreator, existing *mode
existing.Phone = new(model.Phone)
}
changes := existing.Phone.Changes(phone)
if len(changes) == 0 {
return nil, errors.ThrowPreconditionFailed(nil, "EVENT-sp0oc", "no changes found")
}
agg, err = agg.AppendEvent(model.UserPhoneChanged, changes)
if err != nil {
return nil, err
@ -318,6 +351,16 @@ func PhoneVerificationCodeAggregate(aggCreator *es_models.AggregateCreator, exis
}
}
func PhoneCodeSentAggregate(aggCreator *es_models.AggregateCreator, existing *model.User) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
agg, err := UserAggregate(ctx, aggCreator, existing)
if err != nil {
return nil, err
}
return agg.AppendEvent(model.UserPhoneCodeSent, nil)
}
}
func AddressChangeAggregate(aggCreator *es_models.AggregateCreator, existing *model.User, address *model.Address) func(ctx context.Context) (*es_models.Aggregate, error) {
return func(ctx context.Context) (*es_models.Aggregate, error) {
if address == nil {
@ -331,6 +374,9 @@ func AddressChangeAggregate(aggCreator *es_models.AggregateCreator, existing *mo
existing.Address = new(model.Address)
}
changes := existing.Address.Changes(address)
if len(changes) == 0 {
return nil, errors.ThrowPreconditionFailed(nil, "EVENT-2tszw", "no changes found")
}
return agg.AppendEvent(model.UserAddressChanged, changes)
}
}

View File

@ -650,6 +650,54 @@ func TestUserInitCodeAggregate(t *testing.T) {
}
}
func TestInitCodeSentAggregate(t *testing.T) {
type args struct {
ctx context.Context
existing *model.User
aggCreator *models.AggregateCreator
}
type res struct {
eventLen int
eventTypes []models.EventType
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "user init code sent aggregate ok",
args: args{
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: models.ObjectRoot{AggregateID: "ID"}},
aggCreator: models.NewAggregateCreator("Test"),
},
res: res{
eventLen: 1,
eventTypes: []models.EventType{model.InitializedUserCodeSent},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agg, err := UserInitCodeSentAggregate(tt.args.aggCreator, tt.args.existing)(tt.args.ctx)
if tt.res.errFunc == nil && len(agg.Events) != tt.res.eventLen {
t.Errorf("got wrong event len: expected: %v, actual: %v ", tt.res.eventLen, len(agg.Events))
}
for i := 0; i < tt.res.eventLen; i++ {
if tt.res.errFunc == nil && agg.Events[i].Type != tt.res.eventTypes[i] {
t.Errorf("got wrong event type: expected: %v, actual: %v ", tt.res.eventTypes[i], agg.Events[i].Type.String())
}
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestSkipMfaAggregate(t *testing.T) {
type args struct {
ctx context.Context
@ -824,6 +872,54 @@ func TestRequestSetPasswordAggregate(t *testing.T) {
}
}
func TestPasswordCodeSentAggregate(t *testing.T) {
type args struct {
ctx context.Context
existing *model.User
aggCreator *models.AggregateCreator
}
type res struct {
eventLen int
eventTypes []models.EventType
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "user password code sent aggregate ok",
args: args{
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: models.ObjectRoot{AggregateID: "ID"}},
aggCreator: models.NewAggregateCreator("Test"),
},
res: res{
eventLen: 1,
eventTypes: []models.EventType{model.UserPasswordCodeSent},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agg, err := PasswordCodeSentAggregate(tt.args.aggCreator, tt.args.existing)(tt.args.ctx)
if tt.res.errFunc == nil && len(agg.Events) != tt.res.eventLen {
t.Errorf("got wrong event len: expected: %v, actual: %v ", tt.res.eventLen, len(agg.Events))
}
for i := 0; i < tt.res.eventLen; i++ {
if tt.res.errFunc == nil && agg.Events[i].Type != tt.res.eventTypes[i] {
t.Errorf("got wrong event type: expected: %v, actual: %v ", tt.res.eventTypes[i], agg.Events[i].Type.String())
}
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestChangeProfileAggregate(t *testing.T) {
type args struct {
ctx context.Context
@ -846,9 +942,9 @@ func TestChangeProfileAggregate(t *testing.T) {
args: args{
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: models.ObjectRoot{AggregateID: "ID"},
Profile: &model.Profile{UserName: "UserName"},
Profile: &model.Profile{FirstName: "FirstName"},
},
profile: &model.Profile{FirstName: ""},
profile: &model.Profile{FirstName: "FirstNameChanged"},
aggCreator: models.NewAggregateCreator("Test"),
},
res: res{
@ -1114,6 +1210,55 @@ func TestCreateEmailCodeAggregate(t *testing.T) {
})
}
}
func TestEmailCodeSentAggregate(t *testing.T) {
type args struct {
ctx context.Context
existing *model.User
aggCreator *models.AggregateCreator
}
type res struct {
eventLen int
eventTypes []models.EventType
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "user email code sent aggregate ok",
args: args{
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: models.ObjectRoot{AggregateID: "ID"}},
aggCreator: models.NewAggregateCreator("Test"),
},
res: res{
eventLen: 1,
eventTypes: []models.EventType{model.UserEmailCodeSent},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agg, err := EmailCodeSentAggregate(tt.args.aggCreator, tt.args.existing)(tt.args.ctx)
if tt.res.errFunc == nil && len(agg.Events) != tt.res.eventLen {
t.Errorf("got wrong event len: expected: %v, actual: %v ", tt.res.eventLen, len(agg.Events))
}
for i := 0; i < tt.res.eventLen; i++ {
if tt.res.errFunc == nil && agg.Events[i].Type != tt.res.eventTypes[i] {
t.Errorf("got wrong event type: expected: %v, actual: %v ", tt.res.eventTypes[i], agg.Events[i].Type.String())
}
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestChangePhoneAggregate(t *testing.T) {
type args struct {
ctx context.Context
@ -1137,9 +1282,9 @@ func TestChangePhoneAggregate(t *testing.T) {
args: args{
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: models.ObjectRoot{AggregateID: "ID"},
Phone: &model.Phone{PhoneNumber: "PhoneNumber"},
Phone: &model.Phone{PhoneNumber: "+41791234567"},
},
phone: &model.Phone{PhoneNumber: "Changed", IsPhoneVerified: true},
phone: &model.Phone{PhoneNumber: "+41799876543", IsPhoneVerified: true},
aggCreator: models.NewAggregateCreator("Test"),
},
res: res{
@ -1346,6 +1491,54 @@ func TestCreatePhoneCodeAggregate(t *testing.T) {
}
}
func TestPhoneCodeSentAggregate(t *testing.T) {
type args struct {
ctx context.Context
existing *model.User
aggCreator *models.AggregateCreator
}
type res struct {
eventLen int
eventTypes []models.EventType
errFunc func(err error) bool
}
tests := []struct {
name string
args args
res res
}{
{
name: "user phone code sent aggregate ok",
args: args{
ctx: auth.NewMockContext("orgID", "userID"),
existing: &model.User{ObjectRoot: models.ObjectRoot{AggregateID: "ID"}},
aggCreator: models.NewAggregateCreator("Test"),
},
res: res{
eventLen: 1,
eventTypes: []models.EventType{model.UserPhoneCodeSent},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agg, err := PhoneCodeSentAggregate(tt.args.aggCreator, tt.args.existing)(tt.args.ctx)
if tt.res.errFunc == nil && len(agg.Events) != tt.res.eventLen {
t.Errorf("got wrong event len: expected: %v, actual: %v ", tt.res.eventLen, len(agg.Events))
}
for i := 0; i < tt.res.eventLen; i++ {
if tt.res.errFunc == nil && agg.Events[i].Type != tt.res.eventTypes[i] {
t.Errorf("got wrong event type: expected: %v, actual: %v ", tt.res.eventTypes[i], agg.Events[i].Type.String())
}
}
if tt.res.errFunc != nil && !tt.res.errFunc(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func TestChangeAddressAggregate(t *testing.T) {
type args struct {
ctx context.Context

View File

@ -0,0 +1,112 @@
package model
import (
"encoding/json"
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore/models"
"github.com/caos/zitadel/internal/user/model"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"time"
)
const (
NotifyUserKeyUserID = "id"
)
type NotifyUser struct {
ID string `json:"-" gorm:"column:id;primary_key"`
CreationDate time.Time `json:"-" gorm:"column:creation_date"`
ChangeDate time.Time `json:"-" gorm:"column:change_date"`
ResourceOwner string `json:"-" gorm:"column:resource_owner"`
UserName string `json:"userName" gorm:"column:user_name"`
FirstName string `json:"firstName" gorm:"column:first_name"`
LastName string `json:"lastName" gorm:"column:last_name"`
NickName string `json:"nickName" gorm:"column:nick_name"`
DisplayName string `json:"displayName" gorm:"column:display_name"`
PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"`
Gender int32 `json:"gender" gorm:"column:gender"`
LastEmail string `json:"email" gorm:"column:last_email"`
VerifiedEmail string `json:"-" gorm:"column:verified_email"`
LastPhone string `json:"phone" gorm:"column:last_phone"`
VerifiedPhone string `json:"-" gorm:"column:verified_phone"`
Sequence uint64 `json:"-" gorm:"column:sequence"`
}
func NotifyUserFromModel(user *model.NotifyUser) *NotifyUser {
return &NotifyUser{
ID: user.ID,
ChangeDate: user.ChangeDate,
CreationDate: user.CreationDate,
ResourceOwner: user.ResourceOwner,
UserName: user.UserName,
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage,
Gender: int32(user.Gender),
LastEmail: user.LastEmail,
VerifiedEmail: user.VerifiedEmail,
LastPhone: user.LastPhone,
VerifiedPhone: user.VerifiedPhone,
Sequence: user.Sequence,
}
}
func NotifyUserToModel(user *NotifyUser) *model.NotifyUser {
return &model.NotifyUser{
ID: user.ID,
ChangeDate: user.ChangeDate,
CreationDate: user.CreationDate,
ResourceOwner: user.ResourceOwner,
UserName: user.UserName,
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage,
Gender: model.Gender(user.Gender),
LastEmail: user.LastEmail,
VerifiedEmail: user.VerifiedEmail,
LastPhone: user.LastPhone,
VerifiedPhone: user.VerifiedPhone,
Sequence: user.Sequence,
}
}
func (u *NotifyUser) AppendEvent(event *models.Event) (err error) {
u.ChangeDate = event.CreationDate
u.Sequence = event.Sequence
switch event.Type {
case es_model.UserAdded,
es_model.UserRegistered:
u.CreationDate = event.CreationDate
u.setRootData(event)
err = u.setData(event)
case es_model.UserProfileChanged:
err = u.setData(event)
case es_model.UserEmailChanged:
err = u.setData(event)
case es_model.UserEmailVerified:
u.VerifiedEmail = u.LastEmail
case es_model.UserPhoneChanged:
err = u.setData(event)
case es_model.UserPhoneVerified:
u.VerifiedPhone = u.LastPhone
}
return err
}
func (u *NotifyUser) setRootData(event *models.Event) {
u.ID = event.AggregateID
u.ResourceOwner = event.ResourceOwner
}
func (u *NotifyUser) setData(event *models.Event) error {
if err := json.Unmarshal(event.Data, u); err != nil {
logging.Log("EVEN-lso9e").WithError(err).Error("could not unmarshal event data")
return caos_errs.ThrowInternal(nil, "MODEL-8iows", "could not unmarshal data")
}
return nil
}

View File

@ -0,0 +1,113 @@
package model
import (
es_models "github.com/caos/zitadel/internal/eventstore/models"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"testing"
)
func TestNotifyUserAppendEvent(t *testing.T) {
type args struct {
event *es_models.Event
user *NotifyUser
}
tests := []struct {
name string
args args
result *NotifyUser
}{
{
name: "append added user event",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserAdded, ResourceOwner: "OrgID", Data: mockUserData(getFullUser(nil))},
user: &NotifyUser{},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
{
name: "append change user profile event",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserProfileChanged, ResourceOwner: "OrgID", Data: mockProfileData(&es_model.Profile{FirstName: "FirstNameChanged"})},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstNameChanged", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
{
name: "append change user email event",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserEmailChanged, ResourceOwner: "OrgID", Data: mockEmailData(&es_model.Email{EmailAddress: "EmailChanged"})},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "EmailChanged", LastPhone: "Phone"},
},
{
name: "append change user email event, existing email",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserEmailChanged, ResourceOwner: "OrgID", Data: mockEmailData(&es_model.Email{EmailAddress: "EmailChanged"})},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", VerifiedEmail: "Email", LastPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "EmailChanged", VerifiedEmail: "Email", LastPhone: "Phone"},
},
{
name: "append verify user email event",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserEmailVerified, ResourceOwner: "OrgID"},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", VerifiedEmail: "Email", LastPhone: "Phone"},
},
{
name: "append change user phone event",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserPhoneChanged, ResourceOwner: "OrgID", Data: mockPhoneData(&es_model.Phone{PhoneNumber: "PhoneChanged"})},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "PhoneChanged"},
},
{
name: "append change user phone event, existing phone",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserPhoneChanged, ResourceOwner: "OrgID", Data: mockPhoneData(&es_model.Phone{PhoneNumber: "PhoneChanged"})},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone", VerifiedPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "PhoneChanged", VerifiedPhone: "Phone"},
},
{
name: "append verify user phone event",
args: args{
event: &es_models.Event{AggregateID: "AggregateID", Sequence: 1, Type: es_model.UserPhoneVerified, ResourceOwner: "OrgID"},
user: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone"},
},
result: &NotifyUser{ID: "AggregateID", ResourceOwner: "OrgID", UserName: "UserName", FirstName: "FirstName", LastName: "LastName", LastEmail: "Email", LastPhone: "Phone", VerifiedPhone: "Phone"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.args.user.AppendEvent(tt.args.event)
if tt.args.user.ID != tt.result.ID {
t.Errorf("got wrong result ID: expected: %v, actual: %v ", tt.result.ID, tt.args.user.ID)
}
if tt.args.user.FirstName != tt.result.FirstName {
t.Errorf("got wrong result FirstName: expected: %v, actual: %v ", tt.result.FirstName, tt.args.user.FirstName)
}
if tt.args.user.LastName != tt.result.LastName {
t.Errorf("got wrong result FirstName: expected: %v, actual: %v ", tt.result.FirstName, tt.args.user.FirstName)
}
if tt.args.user.ResourceOwner != tt.result.ResourceOwner {
t.Errorf("got wrong result ResourceOwner: expected: %v, actual: %v ", tt.result.ResourceOwner, tt.args.user.ResourceOwner)
}
if tt.args.user.LastEmail != tt.result.LastEmail {
t.Errorf("got wrong result LastEmail: expected: %v, actual: %v ", tt.result.LastEmail, tt.args.user.LastEmail)
}
if tt.args.user.VerifiedEmail != tt.result.VerifiedEmail {
t.Errorf("got wrong result VerifiedEmail: expected: %v, actual: %v ", tt.result.VerifiedEmail, tt.args.user.VerifiedEmail)
}
if tt.args.user.LastPhone != tt.result.LastPhone {
t.Errorf("got wrong result LastPhone: expected: %v, actual: %v ", tt.result.LastPhone, tt.args.user.LastPhone)
}
if tt.args.user.VerifiedPhone != tt.result.VerifiedPhone {
t.Errorf("got wrong result VerifiedPhone: expected: %v, actual: %v ", tt.result.VerifiedPhone, tt.args.user.VerifiedPhone)
}
})
}
}

View File

@ -0,0 +1,25 @@
package view
import (
usr_model "github.com/caos/zitadel/internal/user/model"
"github.com/caos/zitadel/internal/user/repository/view/model"
"github.com/caos/zitadel/internal/view"
"github.com/jinzhu/gorm"
)
func NotifyUserByID(db *gorm.DB, table, userID string) (*model.NotifyUser, error) {
user := new(model.NotifyUser)
query := view.PrepareGetByKey(table, model.UserSearchKey(usr_model.NOTIFYUSERSEARCHKEY_USER_ID), userID)
err := query(db, user)
return user, err
}
func PutNotifyUser(db *gorm.DB, table string, project *model.NotifyUser) error {
save := view.PrepareSave(table)
return save(db, project)
}
func DeleteNotifyUser(db *gorm.DB, table, userID string) error {
delete := view.PrepareDeleteByKey(table, model.UserSearchKey(usr_model.NOTIFYUSERSEARCHKEY_USER_ID), userID)
return delete(db)
}

View File

@ -76,7 +76,7 @@ func LatestFailedEvent(db *gorm.DB, table, viewName string, sequence uint64) (*F
query := PrepareGetByQuery(table, queries...)
err := query(db, failedEvent)
if err == nil {
if err == nil && failedEvent.ViewName != "" {
return failedEvent, nil
}

View File

@ -0,0 +1,69 @@
BEGIN;
CREATE DATABASE notification;
COMMIT;
BEGIN;
CREATE USER notification;
GRANT SELECT, INSERT, UPDATE, DELETE ON DATABASE notification TO notification;
GRANT SELECT, INSERT, UPDATE ON DATABASE eventstore TO notification;
GRANT SELECT, INSERT, UPDATE ON TABLE eventstore.* TO notification;
COMMIT;
BEGIN;
CREATE TABLE notification.locks (
locker_id TEXT,
locked_until TIMESTAMPTZ,
object_type TEXT,
PRIMARY KEY (object_type)
);
CREATE TABLE notification.current_sequences (
view_name TEXT,
current_sequence BIGINT,
PRIMARY KEY (view_name)
);
CREATE TABLE notification.failed_event (
view_name TEXT,
failed_sequence BIGINT,
failure_count SMALLINT,
err_msg TEXT,
PRIMARY KEY (view_name, failed_sequence)
);
CREATE TABLE notification.notify_users (
id TEXT,
creation_date TIMESTAMPTZ,
change_date TIMESTAMPTZ,
resource_owner TEXT,
user_name TEXT,
first_name TEXT,
last_name TEXT,
nick_Name TEXT,
display_name TEXT,
preferred_language TEXT,
gender SMALLINT,
last_email TEXT,
verified_email TEXT,
last_phone TEXT,
verified_phone TEXT,
sequence BIGINT,
PRIMARY KEY (id)
);
COMMIT;