From 6d25a0928f60e4312fc9c8a5d966fb2d3865b531 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 21 Jul 2020 22:50:11 +0200 Subject: [PATCH] extend database --- Makefile | 6 +- assets/migrations/0_empty.up.sql | 7 +- assets/migrations/1_user.down.sql | 7 +- assets/migrations/1_user.up.sql | 76 ++++---- assets/migrations/2_email_tokens.down.sql | 3 + assets/migrations/2_email_tokens.up.sql | 28 +++ assets/migrations/2_external.up.sql | 17 -- ..._external.down.sql => 3_external.down.sql} | 0 assets/migrations/3_external.up.sql | 33 ++++ internal/database/confirmation.sql.go | 101 +++++----- internal/database/email.sql.go | 175 ++++++++++++++---- internal/database/identity.sql.go | 164 ++++++++++++++++ internal/database/migrate.go | 11 ++ internal/database/models.go | 76 +++++--- internal/database/querier.go | 32 ++-- internal/database/reset.sql.go | 86 +++++---- .../database/sql/queries/confirmation.sql | 38 ++-- internal/database/sql/queries/email.sql | 73 ++++++-- internal/database/sql/queries/identity.sql | 48 +++++ internal/database/sql/queries/person.sql | 41 ++++ internal/database/sql/queries/reset.sql | 26 ++- internal/database/sql/queries/user.sql | 19 -- internal/database/sql/schema/schema.sql | 144 ++++++++++---- internal/database/user.sql.go | 79 -------- internal/ldap/ldap.go | 108 ----------- 25 files changed, 896 insertions(+), 502 deletions(-) create mode 100644 assets/migrations/2_email_tokens.down.sql create mode 100644 assets/migrations/2_email_tokens.up.sql delete mode 100644 assets/migrations/2_external.up.sql rename assets/migrations/{2_external.down.sql => 3_external.down.sql} (100%) create mode 100644 assets/migrations/3_external.up.sql create mode 100644 internal/database/identity.sql.go create mode 100644 internal/database/sql/queries/identity.sql create mode 100644 internal/database/sql/queries/person.sql delete mode 100644 internal/database/sql/queries/user.sql delete mode 100644 internal/database/user.sql.go delete mode 100644 internal/ldap/ldap.go diff --git a/Makefile b/Makefile index 71f3db8..7adcb2d 100644 --- a/Makefile +++ b/Makefile @@ -18,9 +18,9 @@ dev: migrate: .env $(GORUN) --tags=dev main.go migrate -u -.PHONY: migratedown -migratedown: .env - $(GORUN) --tags=dev main.go migrate --revision=0 +.PHONY: drop +drop: .env + $(GORUN) --tags=dev main.go migrate --drop-all .PHONY: generate generate: diff --git a/assets/migrations/0_empty.up.sql b/assets/migrations/0_empty.up.sql index e7ffa25..8ca19a6 100644 --- a/assets/migrations/0_empty.up.sql +++ b/assets/migrations/0_empty.up.sql @@ -1 +1,6 @@ --- this file is only here so the database can track a completely empty state. \ No newline at end of file +-- this file is only here so the database can track a completely empty state. + +-- pgcrypto adds functions for generating UUIDs. +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +-- citext adds indexable case-insensitive text fields. +CREATE EXTENSION IF NOT EXISTS "citext"; \ No newline at end of file diff --git a/assets/migrations/1_user.down.sql b/assets/migrations/1_user.down.sql index b1472cf..a68611b 100644 --- a/assets/migrations/1_user.down.sql +++ b/assets/migrations/1_user.down.sql @@ -1,4 +1,5 @@ -DROP TABLE "reset"; -DROP TABLE "confirmation"; DROP TABLE "email"; -DROP TABLE "user"; + +DROP TABLE "person"; + +DROP TABLE "identity"; diff --git a/assets/migrations/1_user.up.sql b/assets/migrations/1_user.up.sql index a626ca6..5ff8262 100644 --- a/assets/migrations/1_user.up.sql +++ b/assets/migrations/1_user.up.sql @@ -1,37 +1,51 @@ -CREATE TABLE "user" ( - "id" bigserial NOT NULL, - "is_admin" boolean NOT NULL DEFAULT false, - "password" bytea NULL, - "created_at" timestamptz NOT NULL DEFAULT NOW(), +-- An Identity is any object that can participate as an actor in the system. +-- It can have groups, permissions, own other objects etc. +CREATE TABLE "identity" ( + "id" bigserial NOT NULL, + "login" citext NULL, + "passphrase" bytea NULL, + "totp_secret" text NULL, + "is_admin" boolean NOT NULL DEFAULT false, + "is_disabled" boolean NOT NULL DEFAULT false, + "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") ); +CREATE UNIQUE INDEX "identity_login_key" ON "identity" ("login"); +-- A person is a human actor within the system, it is linked to exactly one +-- identity. +CREATE TABLE "person" ( + "identity_id" bigint NOT NULL, + "display_name" text NULL, + "first_name" text NULL, + "last_name" text NULL, + "image_url" text NULL, + "zoneinfo" text NULL, + "locale" text NULL, + FOREIGN KEY ("identity_id") + REFERENCES "identity" ("id") + ON DELETE CASCADE + ON UPDATE RESTRICT, + PRIMARY KEY ("identity_id") +); + +-- Email is an email address for an identity (most likely for a person), +-- that may be verified. Zero or one email address assigned to the identity +-- may be "primary", e.g. used for notifications or login. CREATE TABLE "email" ( - "address" text NOT NULL, - "user_id" bigint NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT NOW(), - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, + "address" citext NOT NULL, + "identity_id" bigint NOT NULL, + "is_verified" boolean NOT NULL DEFAULT false, + "is_primary" boolean NOT NULL DEFAULT false, + "created_at" timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY ("identity_id") + REFERENCES "identity" ("id") + ON DELETE CASCADE + ON UPDATE RESTRICT, PRIMARY KEY ("address") ); -CREATE INDEX ON "email" ("user_id"); - -CREATE TABLE "confirmation" ( - "email_address" text NOT NULL, - "user_id" bigint NOT NULL, - "selector" text NOT NULL, - "verifier" bytea NOT NULL, -- hashed - "expires_at" timestamptz NOT NULL DEFAULT NOW(), - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, - PRIMARY KEY ("selector") -); -CREATE INDEX ON "confirmation" ("user_id"); - -CREATE TABLE "reset" ( - "user_id" bigint NOT NULL, - "selector" text NOT NULL, - "verifier" bytea NOT NULL, -- hashed - "expires_at" timestamptz NOT NULL DEFAULT NOW(), - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, - PRIMARY KEY ("selector") -); -CREATE UNIQUE INDEX ON "reset" ("user_id"); +CREATE INDEX "email_is_verified_idx" ON "email" ("is_verified") + WHERE "is_verified" = true; +CREATE INDEX "email_identity_id_idx" ON "email" ("identity_id"); +CREATE UNIQUE INDEX "email_is_primary_key" ON "email" ("identity_id", "is_primary") + WHERE "is_primary" = true; \ No newline at end of file diff --git a/assets/migrations/2_email_tokens.down.sql b/assets/migrations/2_email_tokens.down.sql new file mode 100644 index 0000000..9d4e027 --- /dev/null +++ b/assets/migrations/2_email_tokens.down.sql @@ -0,0 +1,3 @@ +DROP TABLE "email_confirmation"; + +DROP TABLE "password_reset"; \ No newline at end of file diff --git a/assets/migrations/2_email_tokens.up.sql b/assets/migrations/2_email_tokens.up.sql new file mode 100644 index 0000000..37afdd3 --- /dev/null +++ b/assets/migrations/2_email_tokens.up.sql @@ -0,0 +1,28 @@ +-- Email Confirmation tracks all email confirmations that have been sent out. +CREATE TABLE "email_confirmation" ( + "email_address" citext NOT NULL, + "selector" text NOT NULL, + "verifier" bytea NOT NULL, + "valid_until" timestamptz NOT NULL, + FOREIGN KEY ("email_address") + REFERENCES "email" ("address") + ON DELETE CASCADE + ON UPDATE RESTRICT, + PRIMARY KEY ("email_address") +); +CREATE UNIQUE INDEX "email_confirmation_selector_key" + ON "email_confirmation" ("selector"); + +-- Password reset keeps track of the password reset tokens. +CREATE TABLE "password_reset" ( + "identity_id" bigserial NOT NULL, + "selector" text NOT NULL, + "verifier" bytea NOT NULL, + "valid_until" timestamptz NOT NULL, + FOREIGN KEY ("identity_id") + REFERENCES "person" ("identity_id") + ON DELETE CASCADE + ON UPDATE RESTRICT, + PRIMARY KEY ("identity_id") +); +CREATE UNIQUE INDEX "password_reset_selector_key" ON "password_reset" ("selector"); \ No newline at end of file diff --git a/assets/migrations/2_external.up.sql b/assets/migrations/2_external.up.sql deleted file mode 100644 index 763913f..0000000 --- a/assets/migrations/2_external.up.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE "external_auth" ( - "id" bigserial NOT NULL, - "name" text NOT NULL, - "type" text NOT NULL, - "config" jsonb NOT NULL, - PRIMARY KEY ("id") -); -CREATE INDEX ON "external_auth" ("type"); - -CREATE TABLE "external_user" ( - "external_auth_id" bigint NOT NULL, - "foreign_id" text NOT NULL, - "user_id" bigint NOT NULL, - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, - FOREIGN KEY ("external_auth_id") REFERENCES "external_auth" ("id") ON DELETE CASCADE, - PRIMARY KEY ("external_auth_id", "foreign_id") -); \ No newline at end of file diff --git a/assets/migrations/2_external.down.sql b/assets/migrations/3_external.down.sql similarity index 100% rename from assets/migrations/2_external.down.sql rename to assets/migrations/3_external.down.sql diff --git a/assets/migrations/3_external.up.sql b/assets/migrations/3_external.up.sql new file mode 100644 index 0000000..d297037 --- /dev/null +++ b/assets/migrations/3_external.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE "external_auth" ( + "name" text NOT NULL, + "oidc_url" text NULL, + "auth_url" text NOT NULL, + "token_url" text NOT NULL, + "client_key" text NOT NULL, + "client_secret" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("name") +); +CREATE UNIQUE INDEX "external_auth_name_key" ON "external_auth" ("name"); + +CREATE TABLE "external_user" ( + "identity_id" bigint NOT NULL, + "external_auth_name" text NOT NULL, + "external_id" text NOT NULL, + "auth_token" text NULL, + "refresh_token" text NULL, + "identity_token" text NULL, + FOREIGN KEY ("identity_id") + REFERENCES "identity" ("id") + ON UPDATE RESTRICT + ON DELETE CASCADE, + FOREIGN KEY ("external_auth_name") + REFERENCES "external_auth" ("name") + ON UPDATE CASCADE + ON DELETE CASCADE, + PRIMARY KEY ("identity_id") +); +CREATE INDEX "external_user_external_auth_name_idx" + ON "external_user" ("external_auth_name"); +CREATE UNIQUE INDEX "external_user_external_id_key" + ON "external_user" ("external_auth_name", "external_id"); \ No newline at end of file diff --git a/internal/database/confirmation.sql.go b/internal/database/confirmation.sql.go index 5021615..d037b6e 100644 --- a/internal/database/confirmation.sql.go +++ b/internal/database/confirmation.sql.go @@ -8,86 +8,89 @@ import ( "time" ) -const createConfirmation = `-- name: CreateConfirmation :one -INSERT INTO "public"."confirmation" ( -"email_address", "user_id", "selector", "verifier", "expires_at" +const createEmailConfirmation = `-- name: CreateEmailConfirmation :one +INSERT INTO "email_confirmation" ( + "selector", + "verifier", + "valid_until", + "email_address" ) VALUES ( -$1, $2, $3, $4, $5 -) RETURNING email_address, user_id, selector, verifier, expires_at + $1, + $2, + $3, + $4 +) RETURNING email_address, selector, verifier, valid_until ` -type CreateConfirmationParams struct { - EmailAddress string `json:"email_address"` - UserID int64 `json:"user_id"` +type CreateEmailConfirmationParams struct { Selector string `json:"selector"` Verifier []byte `json:"verifier"` - ExpiresAt time.Time `json:"expires_at"` + ValidUntil time.Time `json:"valid_until"` + EmailAddress string `json:"email_address"` } -func (q *Queries) CreateConfirmation(ctx context.Context, arg CreateConfirmationParams) (Confirmation, error) { - row := q.db.QueryRowContext(ctx, createConfirmation, - arg.EmailAddress, - arg.UserID, +func (q *Queries) CreateEmailConfirmation(ctx context.Context, arg CreateEmailConfirmationParams) (EmailConfirmation, error) { + row := q.db.QueryRowContext(ctx, createEmailConfirmation, arg.Selector, arg.Verifier, - arg.ExpiresAt, + arg.ValidUntil, + arg.EmailAddress, ) - var i Confirmation + var i EmailConfirmation err := row.Scan( &i.EmailAddress, - &i.UserID, &i.Selector, &i.Verifier, - &i.ExpiresAt, + &i.ValidUntil, ) return i, err } -const destroyConfirmation = `-- name: DestroyConfirmation :exec -DELETE FROM "public"."confirmation" WHERE "selector" = $1 +const destroyEmailConfirmation = `-- name: DestroyEmailConfirmation :exec +DELETE FROM "email_confirmation" WHERE "email_address" = $1 ` -func (q *Queries) DestroyConfirmation(ctx context.Context, selector string) error { - _, err := q.db.ExecContext(ctx, destroyConfirmation, selector) +func (q *Queries) DestroyEmailConfirmation(ctx context.Context, emailAddress string) error { + _, err := q.db.ExecContext(ctx, destroyEmailConfirmation, emailAddress) return err } -const getConfirmationBySelector = `-- name: GetConfirmationBySelector :one +const getEmailConfirmationByAddress = `-- name: GetEmailConfirmationByAddress :one SELECT -"email_address", "user_id", "selector", "verifier", "expires_at" -FROM "public"."confirmation" + email_address, selector, verifier, valid_until +FROM "email_confirmation" +WHERE "email_address" = $1 +LIMIT 1 +` + +func (q *Queries) GetEmailConfirmationByAddress(ctx context.Context, emailAddress string) (EmailConfirmation, error) { + row := q.db.QueryRowContext(ctx, getEmailConfirmationByAddress, emailAddress) + var i EmailConfirmation + err := row.Scan( + &i.EmailAddress, + &i.Selector, + &i.Verifier, + &i.ValidUntil, + ) + return i, err +} + +const getEmailConfirmationBySelector = `-- name: GetEmailConfirmationBySelector :one +SELECT + email_address, selector, verifier, valid_until +FROM "email_confirmation" WHERE "selector" = $1 +LIMIT 1 ` -func (q *Queries) GetConfirmationBySelector(ctx context.Context, selector string) (Confirmation, error) { - row := q.db.QueryRowContext(ctx, getConfirmationBySelector, selector) - var i Confirmation +func (q *Queries) GetEmailConfirmationBySelector(ctx context.Context, selector string) (EmailConfirmation, error) { + row := q.db.QueryRowContext(ctx, getEmailConfirmationBySelector, selector) + var i EmailConfirmation err := row.Scan( &i.EmailAddress, - &i.UserID, &i.Selector, &i.Verifier, - &i.ExpiresAt, - ) - return i, err -} - -const getConfirmationByUserID = `-- name: GetConfirmationByUserID :one -SELECT -"email_address", "user_id", "selector", "verifier", "expires_at" -FROM "public"."confirmation" -WHERE "user_id" = $1 -` - -func (q *Queries) GetConfirmationByUserID(ctx context.Context, userID int64) (Confirmation, error) { - row := q.db.QueryRowContext(ctx, getConfirmationByUserID, userID) - var i Confirmation - err := row.Scan( - &i.EmailAddress, - &i.UserID, - &i.Selector, - &i.Verifier, - &i.ExpiresAt, + &i.ValidUntil, ) return i, err } diff --git a/internal/database/email.sql.go b/internal/database/email.sql.go index 16af025..6109e78 100644 --- a/internal/database/email.sql.go +++ b/internal/database/email.sql.go @@ -5,27 +5,67 @@ package database import ( "context" - "time" ) +const countEmailsByIdentityID = `-- name: CountEmailsByIdentityID :one +SELECT + COUNT(*) +FROM + "email" +WHERE + "identity_id" = $1 +` + +func (q *Queries) CountEmailsByIdentityID(ctx context.Context, identityID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countEmailsByIdentityID, identityID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countUnverifiedEmailsByIdentityID = `-- name: CountUnverifiedEmailsByIdentityID :one +SELECT + COUNT(*) +FROM + "email" +WHERE + "identity_id" = $1 AND + "is_verified" = FALSE +` + +func (q *Queries) CountUnverifiedEmailsByIdentityID(ctx context.Context, identityID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countUnverifiedEmailsByIdentityID, identityID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createEmail = `-- name: CreateEmail :one INSERT INTO "email" ( -"address", "user_id", "created_at" + "address", + "identity_id", + "is_verified" ) VALUES ( -$1, $2, $3 -) RETURNING address, user_id, created_at + $1, $2, $3 +) RETURNING address, identity_id, is_verified, is_primary, created_at ` type CreateEmailParams struct { - Address string `json:"address"` - UserID int64 `json:"user_id"` - CreatedAt time.Time `json:"created_at"` + Address string `json:"address"` + IdentityID int64 `json:"identity_id"` + IsVerified bool `json:"is_verified"` } func (q *Queries) CreateEmail(ctx context.Context, arg CreateEmailParams) (Email, error) { - row := q.db.QueryRowContext(ctx, createEmail, arg.Address, arg.UserID, arg.CreatedAt) + row := q.db.QueryRowContext(ctx, createEmail, arg.Address, arg.IdentityID, arg.IsVerified) var i Email - err := row.Scan(&i.Address, &i.UserID, &i.CreatedAt) + err := row.Scan( + &i.Address, + &i.IdentityID, + &i.IsVerified, + &i.IsPrimary, + &i.CreatedAt, + ) return i, err } @@ -40,47 +80,118 @@ func (q *Queries) DestroyEmail(ctx context.Context, address string) error { const getEmailByAddress = `-- name: GetEmailByAddress :one SELECT -"address", "user_id", "created_at" -FROM "public"."email" -WHERE "address" = $1 + address, identity_id, is_verified, is_primary, created_at +FROM "email" +WHERE + "address" = $1 ` func (q *Queries) GetEmailByAddress(ctx context.Context, address string) (Email, error) { row := q.db.QueryRowContext(ctx, getEmailByAddress, address) var i Email - err := row.Scan(&i.Address, &i.UserID, &i.CreatedAt) + err := row.Scan( + &i.Address, + &i.IdentityID, + &i.IsVerified, + &i.IsPrimary, + &i.CreatedAt, + ) return i, err } -const getEmailByUserID = `-- name: GetEmailByUserID :one +const getEmailByIdentityID = `-- name: GetEmailByIdentityID :many SELECT -"address", "user_id", "created_at" -FROM "public"."email" -WHERE "user_id" = $1 + address, identity_id, is_verified, is_primary, created_at +FROM "email" +WHERE + "identity_id" = $1 ` -func (q *Queries) GetEmailByUserID(ctx context.Context, userID int64) (Email, error) { - row := q.db.QueryRowContext(ctx, getEmailByUserID, userID) +func (q *Queries) GetEmailByIdentityID(ctx context.Context, identityID int64) ([]Email, error) { + rows, err := q.db.QueryContext(ctx, getEmailByIdentityID, identityID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Email + for rows.Next() { + var i Email + if err := rows.Scan( + &i.Address, + &i.IdentityID, + &i.IsVerified, + &i.IsPrimary, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getPrimaryEmailByIdentityID = `-- name: GetPrimaryEmailByIdentityID :one +SELECT + address, identity_id, is_verified, is_primary, created_at +FROM "email" +WHERE + "identity_id" = $1 AND "is_primary" +` + +func (q *Queries) GetPrimaryEmailByIdentityID(ctx context.Context, identityID int64) (Email, error) { + row := q.db.QueryRowContext(ctx, getPrimaryEmailByIdentityID, identityID) var i Email - err := row.Scan(&i.Address, &i.UserID, &i.CreatedAt) + err := row.Scan( + &i.Address, + &i.IdentityID, + &i.IsVerified, + &i.IsPrimary, + &i.CreatedAt, + ) return i, err } -const updateEmail = `-- name: UpdateEmail :exec -UPDATE "email" SET ( -"user_id", "created_at" -) = ( -$1, $2 -) WHERE "address" = $3 +const updateEmailPrimary = `-- name: UpdateEmailPrimary :exec +UPDATE "email" +SET + "is_primary" = NOT "is_primary" +WHERE + "identity_id" = $1 AND ( + ( "address" <> $2 AND "is_primary" = true ) OR + ( "address" = $2 AND "is_primary" = false AND "is_verified" = true ) + ) ` -type UpdateEmailParams struct { - UserID int64 `json:"user_id"` - CreatedAt time.Time `json:"created_at"` - Address string `json:"address"` +type UpdateEmailPrimaryParams struct { + IdentityID int64 `json:"identity_id"` + Address string `json:"address"` } -func (q *Queries) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error { - _, err := q.db.ExecContext(ctx, updateEmail, arg.UserID, arg.CreatedAt, arg.Address) +// UpdateEmailPrimary sets exactly one primary email for an identity. +// The mail address has to have been verified. +// this query basically combines these two queries into one atomic one: +// UPDATE "email" SET "is_primary" = false WHERE "identity_id" = $1; +// UPDATE "email" SET "is_primary" = true WHERE "identity_id" = $1 AND "address" = $2 AND "is_verified" = true; +func (q *Queries) UpdateEmailPrimary(ctx context.Context, arg UpdateEmailPrimaryParams) error { + _, err := q.db.ExecContext(ctx, updateEmailPrimary, arg.IdentityID, arg.Address) + return err +} + +const updateEmailVerified = `-- name: UpdateEmailVerified :exec +UPDATE "email" SET ( + "is_verified" +) = ( + $2 +) WHERE "address" = $1 +` + +func (q *Queries) UpdateEmailVerified(ctx context.Context, address string) error { + _, err := q.db.ExecContext(ctx, updateEmailVerified, address) return err } diff --git a/internal/database/identity.sql.go b/internal/database/identity.sql.go new file mode 100644 index 0000000..5689be7 --- /dev/null +++ b/internal/database/identity.sql.go @@ -0,0 +1,164 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: identity.sql + +package database + +import ( + "context" + "database/sql" + "time" +) + +const createIdentity = `-- name: CreateIdentity :one +INSERT INTO "identity" ( + "login", + "passphrase", + "is_admin", + "is_disabled" +) VALUES ( + $1, + $2, + $3, + $4 +) RETURNING id, login, passphrase, totp_secret, is_admin, is_disabled, created_at +` + +type CreateIdentityParams struct { + Login sql.NullString `json:"login"` + Passphrase []byte `json:"passphrase"` + IsAdmin bool `json:"is_admin"` + IsDisabled bool `json:"is_disabled"` +} + +func (q *Queries) CreateIdentity(ctx context.Context, arg CreateIdentityParams) (Identity, error) { + row := q.db.QueryRowContext(ctx, createIdentity, + arg.Login, + arg.Passphrase, + arg.IsAdmin, + arg.IsDisabled, + ) + var i Identity + err := row.Scan( + &i.ID, + &i.Login, + &i.Passphrase, + &i.TotpSecret, + &i.IsAdmin, + &i.IsDisabled, + &i.CreatedAt, + ) + return i, err +} + +const getIdentityByID = `-- name: GetIdentityByID :one +SELECT id, login, passphrase, totp_secret, is_admin, is_disabled, created_at FROM "identity" + WHERE "id" = $1 + LIMIT 1 +` + +func (q *Queries) GetIdentityByID(ctx context.Context, id int64) (Identity, error) { + row := q.db.QueryRowContext(ctx, getIdentityByID, id) + var i Identity + err := row.Scan( + &i.ID, + &i.Login, + &i.Passphrase, + &i.TotpSecret, + &i.IsAdmin, + &i.IsDisabled, + &i.CreatedAt, + ) + return i, err +} + +const getIdentityByLogin = `-- name: GetIdentityByLogin :one +SELECT id, login, passphrase, totp_secret, is_admin, is_disabled, created_at FROM "identity" + WHERE "login" = $1 + LIMIT 1 +` + +func (q *Queries) GetIdentityByLogin(ctx context.Context, login sql.NullString) (Identity, error) { + row := q.db.QueryRowContext(ctx, getIdentityByLogin, login) + var i Identity + err := row.Scan( + &i.ID, + &i.Login, + &i.Passphrase, + &i.TotpSecret, + &i.IsAdmin, + &i.IsDisabled, + &i.CreatedAt, + ) + return i, err +} + +const getIdentityByPrimaryEmail = `-- name: GetIdentityByPrimaryEmail :one +SELECT id, login, passphrase, totp_secret, is_admin, is_disabled, identity.created_at, address, identity_id, is_verified, is_primary, email.created_at FROM "identity" + INNER JOIN "email" + ON "email"."identity_id" = "identity"."id" + WHERE + "email"."address" = $1 AND + "email"."is_primary" + LIMIT 1 +` + +type GetIdentityByPrimaryEmailRow struct { + ID int64 `json:"id"` + Login sql.NullString `json:"login"` + Passphrase []byte `json:"passphrase"` + TotpSecret sql.NullString `json:"totp_secret"` + IsAdmin bool `json:"is_admin"` + IsDisabled bool `json:"is_disabled"` + CreatedAt time.Time `json:"created_at"` + Address string `json:"address"` + IdentityID int64 `json:"identity_id"` + IsVerified bool `json:"is_verified"` + IsPrimary bool `json:"is_primary"` + CreatedAt_2 time.Time `json:"created_at_2"` +} + +func (q *Queries) GetIdentityByPrimaryEmail(ctx context.Context, address string) (GetIdentityByPrimaryEmailRow, error) { + row := q.db.QueryRowContext(ctx, getIdentityByPrimaryEmail, address) + var i GetIdentityByPrimaryEmailRow + err := row.Scan( + &i.ID, + &i.Login, + &i.Passphrase, + &i.TotpSecret, + &i.IsAdmin, + &i.IsDisabled, + &i.CreatedAt, + &i.Address, + &i.IdentityID, + &i.IsVerified, + &i.IsPrimary, + &i.CreatedAt_2, + ) + return i, err +} + +const updateIdentityLogin = `-- name: UpdateIdentityLogin :exec +UPDATE "identity" SET ( + "login" +) = ( + $2 +) WHERE "id" = $1 +` + +func (q *Queries) UpdateIdentityLogin(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, updateIdentityLogin, id) + return err +} + +const updateIdentityPassphrase = `-- name: UpdateIdentityPassphrase :exec +UPDATE "identity" SET ( + "passphrase" +) = ( + $2 +) WHERE "id" = $1 +` + +func (q *Queries) UpdateIdentityPassphrase(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, updateIdentityPassphrase, id) + return err +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go index e976b38..99d7a9b 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -54,6 +54,17 @@ const ( MigrateUp = -1 ) +// GetCurrentMigration returns the currently active migration version. +// If no migration has been applied yet, it will return ErrNilVersion. +func GetCurrentMigration(db *sqlx.DB) (version uint, dirty bool, err error) { + migrator, err := getMigrator(db.DB, http.Dir("/invalid/path/unused"), "/unused") + if err != nil { + return 0, false, err + } + + return migrator.Version() +} + // Migrate Migrates the schema of the supplied Database. Supported // methods are: // * database.MigrateUp – Migrate to the latest version diff --git a/internal/database/models.go b/internal/database/models.go index 3a04616..9218a1b 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -3,47 +3,67 @@ package database import ( - "encoding/json" + "database/sql" "time" ) -type Confirmation struct { - EmailAddress string `json:"email_address"` - UserID int64 `json:"user_id"` - Selector string `json:"selector"` - Verifier []byte `json:"verifier"` - ExpiresAt time.Time `json:"expires_at"` +type Email struct { + Address string `json:"address"` + IdentityID int64 `json:"identity_id"` + IsVerified bool `json:"is_verified"` + IsPrimary bool `json:"is_primary"` + CreatedAt time.Time `json:"created_at"` } -type Email struct { - Address string `json:"address"` - UserID int64 `json:"user_id"` - CreatedAt time.Time `json:"created_at"` +type EmailConfirmation struct { + EmailAddress string `json:"email_address"` + Selector string `json:"selector"` + Verifier []byte `json:"verifier"` + ValidUntil time.Time `json:"valid_until"` } type ExternalAuth struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Config json.RawMessage `json:"config"` + Name string `json:"name"` + OidcUrl sql.NullString `json:"oidc_url"` + AuthUrl string `json:"auth_url"` + TokenUrl string `json:"token_url"` + ClientKey string `json:"client_key"` + ClientSecret string `json:"client_secret"` + CreatedAt time.Time `json:"created_at"` } type ExternalUser struct { - ExternalAuthID int64 `json:"external_auth_id"` - ForeignID string `json:"foreign_id"` - UserID int64 `json:"user_id"` + IdentityID int64 `json:"identity_id"` + ExternalAuthName string `json:"external_auth_name"` + ExternalID string `json:"external_id"` + AuthToken sql.NullString `json:"auth_token"` + RefreshToken sql.NullString `json:"refresh_token"` + IdentityToken sql.NullString `json:"identity_token"` } -type Reset struct { - UserID int64 `json:"user_id"` - Selector string `json:"selector"` - Verifier []byte `json:"verifier"` - ExpiresAt time.Time `json:"expires_at"` +type Identity struct { + ID int64 `json:"id"` + Login sql.NullString `json:"login"` + Passphrase []byte `json:"passphrase"` + TotpSecret sql.NullString `json:"totp_secret"` + IsAdmin bool `json:"is_admin"` + IsDisabled bool `json:"is_disabled"` + CreatedAt time.Time `json:"created_at"` } -type User struct { - ID int64 `json:"id"` - IsAdmin bool `json:"is_admin"` - Password []byte `json:"password"` - CreatedAt time.Time `json:"created_at"` +type PasswordReset struct { + IdentityID int64 `json:"identity_id"` + Selector string `json:"selector"` + Verifier []byte `json:"verifier"` + ValidUntil time.Time `json:"valid_until"` +} + +type Person struct { + IdentityID int64 `json:"identity_id"` + DisplayName sql.NullString `json:"display_name"` + FirstName sql.NullString `json:"first_name"` + LastName sql.NullString `json:"last_name"` + ImageUrl sql.NullString `json:"image_url"` + Zoneinfo sql.NullString `json:"zoneinfo"` + Locale sql.NullString `json:"locale"` } diff --git a/internal/database/querier.go b/internal/database/querier.go index fe2289b..f12085a 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -4,25 +4,33 @@ package database import ( "context" + "database/sql" ) type Querier interface { - CreateConfirmation(ctx context.Context, arg CreateConfirmationParams) (Confirmation, error) + CountEmailsByIdentityID(ctx context.Context, identityID int64) (int64, error) + CountUnverifiedEmailsByIdentityID(ctx context.Context, identityID int64) (int64, error) CreateEmail(ctx context.Context, arg CreateEmailParams) (Email, error) - CreateReset(ctx context.Context, arg CreateResetParams) (Reset, error) - CreateUser(ctx context.Context, arg CreateUserParams) (User, error) - DestroyConfirmation(ctx context.Context, selector string) error + CreateEmailConfirmation(ctx context.Context, arg CreateEmailConfirmationParams) (EmailConfirmation, error) + CreateIdentity(ctx context.Context, arg CreateIdentityParams) (Identity, error) + CreateReset(ctx context.Context, arg CreateResetParams) (PasswordReset, error) DestroyEmail(ctx context.Context, address string) error + DestroyEmailConfirmation(ctx context.Context, emailAddress string) error DestroyReset(ctx context.Context, selector string) error - GetConfirmationBySelector(ctx context.Context, selector string) (Confirmation, error) - GetConfirmationByUserID(ctx context.Context, userID int64) (Confirmation, error) GetEmailByAddress(ctx context.Context, address string) (Email, error) - GetEmailByUserID(ctx context.Context, userID int64) (Email, error) - GetResetBySelector(ctx context.Context, selector string) (Reset, error) - GetResetByUserID(ctx context.Context, userID int64) (Reset, error) - GetUserByID(ctx context.Context, id int64) (User, error) - UpdateEmail(ctx context.Context, arg UpdateEmailParams) error - UpdateUser(ctx context.Context, arg UpdateUserParams) error + GetEmailByIdentityID(ctx context.Context, identityID int64) ([]Email, error) + GetEmailConfirmationByAddress(ctx context.Context, emailAddress string) (EmailConfirmation, error) + GetEmailConfirmationBySelector(ctx context.Context, selector string) (EmailConfirmation, error) + GetIdentityByID(ctx context.Context, id int64) (Identity, error) + GetIdentityByLogin(ctx context.Context, login sql.NullString) (Identity, error) + GetIdentityByPrimaryEmail(ctx context.Context, address string) (GetIdentityByPrimaryEmailRow, error) + GetPrimaryEmailByIdentityID(ctx context.Context, identityID int64) (Email, error) + GetResetByIdentityID(ctx context.Context, identityID int64) (PasswordReset, error) + GetResetBySelector(ctx context.Context, selector string) (PasswordReset, error) + UpdateEmailPrimary(ctx context.Context, arg UpdateEmailPrimaryParams) error + UpdateEmailVerified(ctx context.Context, address string) error + UpdateIdentityLogin(ctx context.Context, id int64) error + UpdateIdentityPassphrase(ctx context.Context, id int64) error } var _ Querier = (*Queries)(nil) diff --git a/internal/database/reset.sql.go b/internal/database/reset.sql.go index bbec7a2..19ac08c 100644 --- a/internal/database/reset.sql.go +++ b/internal/database/reset.sql.go @@ -9,39 +9,45 @@ import ( ) const createReset = `-- name: CreateReset :one -INSERT INTO "reset" ( -"user_id", "selector", "verifier", "expires_at" +INSERT INTO "password_reset" ( + "identity_id", + "selector", + "verifier", + "valid_until" ) VALUES ( -$1, $2, $3, $4 -) RETURNING user_id, selector, verifier, expires_at + $1, + $2, + $3, + $4 +) RETURNING identity_id, selector, verifier, valid_until ` type CreateResetParams struct { - UserID int64 `json:"user_id"` - Selector string `json:"selector"` - Verifier []byte `json:"verifier"` - ExpiresAt time.Time `json:"expires_at"` + IdentityID int64 `json:"identity_id"` + Selector string `json:"selector"` + Verifier []byte `json:"verifier"` + ValidUntil time.Time `json:"valid_until"` } -func (q *Queries) CreateReset(ctx context.Context, arg CreateResetParams) (Reset, error) { +func (q *Queries) CreateReset(ctx context.Context, arg CreateResetParams) (PasswordReset, error) { row := q.db.QueryRowContext(ctx, createReset, - arg.UserID, + arg.IdentityID, arg.Selector, arg.Verifier, - arg.ExpiresAt, + arg.ValidUntil, ) - var i Reset + var i PasswordReset err := row.Scan( - &i.UserID, + &i.IdentityID, &i.Selector, &i.Verifier, - &i.ExpiresAt, + &i.ValidUntil, ) return i, err } const destroyReset = `-- name: DestroyReset :exec -DELETE FROM "reset" WHERE "selector" = $1 +DELETE FROM "password_reset" WHERE "selector" = $1 ` func (q *Queries) DestroyReset(ctx context.Context, selector string) error { @@ -49,40 +55,40 @@ func (q *Queries) DestroyReset(ctx context.Context, selector string) error { return err } +const getResetByIdentityID = `-- name: GetResetByIdentityID :one +SELECT + identity_id, selector, verifier, valid_until +FROM "password_reset" +WHERE "identity_id" = $1 +` + +func (q *Queries) GetResetByIdentityID(ctx context.Context, identityID int64) (PasswordReset, error) { + row := q.db.QueryRowContext(ctx, getResetByIdentityID, identityID) + var i PasswordReset + err := row.Scan( + &i.IdentityID, + &i.Selector, + &i.Verifier, + &i.ValidUntil, + ) + return i, err +} + const getResetBySelector = `-- name: GetResetBySelector :one SELECT -"user_id", "selector", "verifier", "expires_at" -FROM "reset" + identity_id, selector, verifier, valid_until +FROM "password_reset" WHERE "selector" = $1 ` -func (q *Queries) GetResetBySelector(ctx context.Context, selector string) (Reset, error) { +func (q *Queries) GetResetBySelector(ctx context.Context, selector string) (PasswordReset, error) { row := q.db.QueryRowContext(ctx, getResetBySelector, selector) - var i Reset + var i PasswordReset err := row.Scan( - &i.UserID, + &i.IdentityID, &i.Selector, &i.Verifier, - &i.ExpiresAt, - ) - return i, err -} - -const getResetByUserID = `-- name: GetResetByUserID :one -SELECT -"user_id", "selector", "verifier", "expires_at" -FROM "reset" -WHERE "user_id" = $1 -` - -func (q *Queries) GetResetByUserID(ctx context.Context, userID int64) (Reset, error) { - row := q.db.QueryRowContext(ctx, getResetByUserID, userID) - var i Reset - err := row.Scan( - &i.UserID, - &i.Selector, - &i.Verifier, - &i.ExpiresAt, + &i.ValidUntil, ) return i, err } diff --git a/internal/database/sql/queries/confirmation.sql b/internal/database/sql/queries/confirmation.sql index bf61aa7..2b7acb7 100644 --- a/internal/database/sql/queries/confirmation.sql +++ b/internal/database/sql/queries/confirmation.sql @@ -1,21 +1,29 @@ --- name: CreateConfirmation :one -INSERT INTO "public"."confirmation" ( -"email_address", "user_id", "selector", "verifier", "expires_at" +-- name: CreateEmailConfirmation :one +INSERT INTO "email_confirmation" ( + "selector", + "verifier", + "valid_until", + "email_address" ) VALUES ( -$1, $2, $3, $4, $5 + $1, + $2, + $3, + $4 ) RETURNING *; --- name: DestroyConfirmation :exec -DELETE FROM "public"."confirmation" WHERE "selector" = $1; - --- name: GetConfirmationBySelector :one +-- name: GetEmailConfirmationBySelector :one SELECT -"email_address", "user_id", "selector", "verifier", "expires_at" -FROM "public"."confirmation" -WHERE "selector" = $1; + * +FROM "email_confirmation" +WHERE "selector" = $1 +LIMIT 1; --- name: GetConfirmationByUserID :one +-- name: GetEmailConfirmationByAddress :one SELECT -"email_address", "user_id", "selector", "verifier", "expires_at" -FROM "public"."confirmation" -WHERE "user_id" = $1; \ No newline at end of file + * +FROM "email_confirmation" +WHERE "email_address" = $1 +LIMIT 1; + +-- name: DestroyEmailConfirmation :exec +DELETE FROM "email_confirmation" WHERE "email_address" = $1; \ No newline at end of file diff --git a/internal/database/sql/queries/email.sql b/internal/database/sql/queries/email.sql index acbe283..9742d63 100644 --- a/internal/database/sql/queries/email.sql +++ b/internal/database/sql/queries/email.sql @@ -1,28 +1,71 @@ -- name: CreateEmail :one INSERT INTO "email" ( -"address", "user_id", "created_at" + "address", + "identity_id", + "is_verified" ) VALUES ( -$1, $2, $3 + $1, $2, $3 ) RETURNING *; --- name: UpdateEmail :exec +-- name: UpdateEmailVerified :exec UPDATE "email" SET ( -"user_id", "created_at" + "is_verified" ) = ( -$1, $2 -) WHERE "address" = $3; + $2 +) WHERE "address" = $1; --- name: DestroyEmail :exec -DELETE FROM "email" WHERE "address" = $1; +-- name: UpdateEmailPrimary :exec +-- UpdateEmailPrimary sets exactly one primary email for an identity. +-- The mail address has to have been verified. +-- this query basically combines these two queries into one atomic one: +-- UPDATE "email" SET "is_primary" = false WHERE "identity_id" = $1; +-- UPDATE "email" SET "is_primary" = true WHERE "identity_id" = $1 AND "address" = $2 AND "is_verified" = true; +UPDATE "email" +SET + "is_primary" = NOT "is_primary" +WHERE + "identity_id" = $1 AND ( + ( "address" <> $2 AND "is_primary" = true ) OR + ( "address" = $2 AND "is_primary" = false AND "is_verified" = true ) + ); -- name: GetEmailByAddress :one SELECT -"address", "user_id", "created_at" -FROM "public"."email" -WHERE "address" = $1; + * +FROM "email" +WHERE + "address" = $1; --- name: GetEmailByUserID :one +-- name: GetEmailByIdentityID :many SELECT -"address", "user_id", "created_at" -FROM "public"."email" -WHERE "user_id" = $1; \ No newline at end of file + * +FROM "email" +WHERE + "identity_id" = $1; + +-- name: GetPrimaryEmailByIdentityID :one +SELECT + * +FROM "email" +WHERE + "identity_id" = $1 AND "is_primary"; + +-- name: CountEmailsByIdentityID :one +SELECT + COUNT(*) +FROM + "email" +WHERE + "identity_id" = $1; + +-- name: CountUnverifiedEmailsByIdentityID :one +SELECT + COUNT(*) +FROM + "email" +WHERE + "identity_id" = $1 AND + "is_verified" = FALSE; + +-- name: DestroyEmail :exec +DELETE FROM "email" WHERE "address" = $1; \ No newline at end of file diff --git a/internal/database/sql/queries/identity.sql b/internal/database/sql/queries/identity.sql new file mode 100644 index 0000000..5f3dbe3 --- /dev/null +++ b/internal/database/sql/queries/identity.sql @@ -0,0 +1,48 @@ +-- name: CreateIdentity :one +INSERT INTO "identity" ( + "login", + "passphrase", + "is_admin", + "is_disabled" +) VALUES ( + $1, + $2, + $3, + $4 +) RETURNING *; + +-- name: GetIdentityByID :one +SELECT * FROM "identity" + WHERE "id" = $1 + LIMIT 1; + +-- name: GetIdentityByLogin :one +SELECT * FROM "identity" + WHERE "login" = $1 + LIMIT 1; + +-- name: GetIdentityByPrimaryEmail :one +SELECT * FROM "identity" + INNER JOIN "email" + ON "email"."identity_id" = "identity"."id" + WHERE + "email"."address" = $1 AND + "email"."is_primary" + LIMIT 1; + +-- name: UpdateIdentityPassphrase :exec +UPDATE "identity" SET ( + "passphrase" +) = ( + $2 +) WHERE "id" = $1; + +-- name: UpdateIdentityLogin :exec +UPDATE "identity" SET ( + "login" +) = ( + $2 +) WHERE "id" = $1; + +-- Name: DestroyIdentity :exec +DELETE FROM "identity" WHERE "id" = $1; \ No newline at end of file diff --git a/internal/database/sql/queries/person.sql b/internal/database/sql/queries/person.sql new file mode 100644 index 0000000..d2e960d --- /dev/null +++ b/internal/database/sql/queries/person.sql @@ -0,0 +1,41 @@ +-- Name: CreatePerson :one +INSERT INTO "person" ( + "identity_id", + "display_name", + "first_name", + "last_name", + "image_url", + "zoneinfo", + "locale" +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) RETURNING *; + +-- Name: GetPersonByIdentityID :one +SELECT * FROM "person" +WHERE + "identity_id" = $1 +LIMIT 1; + +-- Name: UpdatePerson :exec +UPDATE "person" SET ( + "display_name", + "first_name", + "last_name", + "image_url", + "zoneinfo", + "locale" +) = ( + $2, + $3, + $4, + $5, + $6, + $7 +) WHERE "identity_id" = $1; \ No newline at end of file diff --git a/internal/database/sql/queries/reset.sql b/internal/database/sql/queries/reset.sql index c71e3a0..3f9a6a7 100644 --- a/internal/database/sql/queries/reset.sql +++ b/internal/database/sql/queries/reset.sql @@ -1,21 +1,27 @@ -- name: CreateReset :one -INSERT INTO "reset" ( -"user_id", "selector", "verifier", "expires_at" +INSERT INTO "password_reset" ( + "identity_id", + "selector", + "verifier", + "valid_until" ) VALUES ( -$1, $2, $3, $4 + $1, + $2, + $3, + $4 ) RETURNING *; -- name: DestroyReset :exec -DELETE FROM "reset" WHERE "selector" = $1; +DELETE FROM "password_reset" WHERE "selector" = $1; -- name: GetResetBySelector :one SELECT -"user_id", "selector", "verifier", "expires_at" -FROM "reset" + * +FROM "password_reset" WHERE "selector" = $1; --- name: GetResetByUserID :one +-- name: GetResetByIdentityID :one SELECT -"user_id", "selector", "verifier", "expires_at" -FROM "reset" -WHERE "user_id" = $1; \ No newline at end of file + * +FROM "password_reset" +WHERE "identity_id" = $1; \ No newline at end of file diff --git a/internal/database/sql/queries/user.sql b/internal/database/sql/queries/user.sql deleted file mode 100644 index 8b48299..0000000 --- a/internal/database/sql/queries/user.sql +++ /dev/null @@ -1,19 +0,0 @@ --- name: GetUserByID :one -SELECT -"id", "is_admin", "password", "created_at" -FROM "user" -WHERE "id" = $1; - --- name: CreateUser :one -INSERT INTO "user" ( -"is_admin", "password", "created_at" -) VALUES ( -$1, $2, $3 -) RETURNING *; - --- name: UpdateUser :exec -UPDATE "user" SET ( -"is_admin", "password", "created_at" -) = ( -$1, $2, $3 -) WHERE "id" = $4; \ No newline at end of file diff --git a/internal/database/sql/schema/schema.sql b/internal/database/sql/schema/schema.sql index 53d832c..e9989f9 100644 --- a/internal/database/sql/schema/schema.sql +++ b/internal/database/sql/schema/schema.sql @@ -1,58 +1,122 @@ -- This file is used for generating queries; If you change anything please -- also add migrations in `assets/migrations`. -CREATE TABLE "user" ( - "id" bigserial NOT NULL, - "is_admin" boolean NOT NULL DEFAULT false, - "password" bytea NULL, - "created_at" timestamptz NOT NULL DEFAULT NOW(), +-- pgcrypto adds functions for generating UUIDs. +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +-- citext adds indexable case-insensitive text fields. +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- An Identity is any object that can participate as an actor in the system. +-- It can have groups, permissions, own other objects etc. +CREATE TABLE "identity" ( + "id" bigserial NOT NULL, + "login" citext NULL, + "passphrase" bytea NULL, + "totp_secret" text NULL, + "is_admin" boolean NOT NULL DEFAULT false, + "is_disabled" boolean NOT NULL DEFAULT false, + "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") ); +CREATE UNIQUE INDEX "identity_login_key" ON "identity" ("login"); +-- A person is a human actor within the system, it is linked to exactly one +-- identity. +CREATE TABLE "person" ( + "identity_id" bigint NOT NULL, + "display_name" text NULL, + "first_name" text NULL, + "last_name" text NULL, + "image_url" text NULL, + "zoneinfo" text NULL, + "locale" text NULL, + FOREIGN KEY ("identity_id") + REFERENCES "identity" ("id") + ON DELETE CASCADE + ON UPDATE RESTRICT, + PRIMARY KEY ("identity_id") +); + +-- Email is an email address for an identity (most likely for a person), +-- that may be verified. Zero or one email address assigned to the identity +-- may be "primary", e.g. used for notifications or login. CREATE TABLE "email" ( - "address" text NOT NULL, - "user_id" bigint NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT NOW(), - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, + "address" citext NOT NULL, + "identity_id" bigint NOT NULL, + "is_verified" boolean NOT NULL DEFAULT false, + "is_primary" boolean NOT NULL DEFAULT false, + "created_at" timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY ("identity_id") + REFERENCES "identity" ("id") + ON DELETE CASCADE + ON UPDATE RESTRICT, PRIMARY KEY ("address") ); -CREATE INDEX ON "email" ("user_id"); +CREATE INDEX "email_is_verified_idx" ON "email" ("is_verified") + WHERE "is_verified" = true; +CREATE INDEX "email_identity_id_idx" ON "email" ("identity_id"); +CREATE UNIQUE INDEX "email_is_primary_key" ON "email" ("identity_id", "is_primary") + WHERE "is_primary" = true; -CREATE TABLE "confirmation" ( - "email_address" text NOT NULL, - "user_id" bigint NOT NULL, - "selector" text NOT NULL, - "verifier" bytea NOT NULL, -- hashed - "expires_at" timestamptz NOT NULL DEFAULT NOW(), - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, - PRIMARY KEY ("selector") +-- Email Confirmation tracks all email confirmations that have been sent out. +CREATE TABLE "email_confirmation" ( + "email_address" citext NOT NULL, + "selector" text NOT NULL, + "verifier" bytea NOT NULL, + "valid_until" timestamptz NOT NULL, + FOREIGN KEY ("email_address") + REFERENCES "email" ("address") + ON DELETE CASCADE + ON UPDATE RESTRICT, + PRIMARY KEY ("email_address") ); -CREATE INDEX ON "confirmation" ("user_id"); +CREATE UNIQUE INDEX "email_confirmation_selector_key" + ON "email_confirmation" ("selector"); -CREATE TABLE "reset" ( - "user_id" bigint NOT NULL, - "selector" text NOT NULL, - "verifier" bytea NOT NULL, -- hashed - "expires_at" timestamptz NOT NULL DEFAULT NOW(), - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, - PRIMARY KEY ("selector") +-- Password reset keeps track of the password reset tokens. +CREATE TABLE "password_reset" ( + "identity_id" bigserial NOT NULL, + "selector" text NOT NULL, + "verifier" bytea NOT NULL, + "valid_until" timestamptz NOT NULL, + FOREIGN KEY ("identity_id") + REFERENCES "person" ("identity_id") + ON DELETE CASCADE + ON UPDATE RESTRICT, + PRIMARY KEY ("identity_id") ); -CREATE UNIQUE INDEX ON "reset" ("user_id"); +CREATE UNIQUE INDEX "password_reset_selector_key" ON "password_reset" ("selector"); CREATE TABLE "external_auth" ( - "id" bigserial NOT NULL, - "name" text NOT NULL, - "type" text NOT NULL, - "config" jsonb NOT NULL, - PRIMARY KEY ("id") + "name" text NOT NULL, + "oidc_url" text NULL, + "auth_url" text NOT NULL, + "token_url" text NOT NULL, + "client_key" text NOT NULL, + "client_secret" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("name") ); -CREATE INDEX ON "external_auth" ("type"); +CREATE UNIQUE INDEX "external_auth_name_key" ON "external_auth" ("name"); CREATE TABLE "external_user" ( - "external_auth_id" bigint NOT NULL, - "foreign_id" text NOT NULL, - "user_id" bigint NOT NULL, - FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, - FOREIGN KEY ("external_auth_id") REFERENCES "external_auth" ("id") ON DELETE CASCADE, - PRIMARY KEY ("external_auth_id", "foreign_id") -); \ No newline at end of file + "identity_id" bigint NOT NULL, + "external_auth_name" text NOT NULL, + "external_id" text NOT NULL, + "auth_token" text NULL, + "refresh_token" text NULL, + "identity_token" text NULL, + FOREIGN KEY ("identity_id") + REFERENCES "identity" ("id") + ON UPDATE RESTRICT + ON DELETE CASCADE, + FOREIGN KEY ("external_auth_name") + REFERENCES "external_auth" ("name") + ON UPDATE CASCADE + ON DELETE CASCADE, + PRIMARY KEY ("identity_id") +); +CREATE INDEX "external_user_external_auth_name_idx" + ON "external_user" ("external_auth_name"); +CREATE UNIQUE INDEX "external_user_external_id_key" + ON "external_user" ("external_auth_name", "external_id"); \ No newline at end of file diff --git a/internal/database/user.sql.go b/internal/database/user.sql.go deleted file mode 100644 index 861fa32..0000000 --- a/internal/database/user.sql.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// source: user.sql - -package database - -import ( - "context" - "time" -) - -const createUser = `-- name: CreateUser :one -INSERT INTO "user" ( -"is_admin", "password", "created_at" -) VALUES ( -$1, $2, $3 -) RETURNING id, is_admin, password, created_at -` - -type CreateUserParams struct { - IsAdmin bool `json:"is_admin"` - Password []byte `json:"password"` - CreatedAt time.Time `json:"created_at"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { - row := q.db.QueryRowContext(ctx, createUser, arg.IsAdmin, arg.Password, arg.CreatedAt) - var i User - err := row.Scan( - &i.ID, - &i.IsAdmin, - &i.Password, - &i.CreatedAt, - ) - return i, err -} - -const getUserByID = `-- name: GetUserByID :one -SELECT -"id", "is_admin", "password", "created_at" -FROM "user" -WHERE "id" = $1 -` - -func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { - row := q.db.QueryRowContext(ctx, getUserByID, id) - var i User - err := row.Scan( - &i.ID, - &i.IsAdmin, - &i.Password, - &i.CreatedAt, - ) - return i, err -} - -const updateUser = `-- name: UpdateUser :exec -UPDATE "user" SET ( -"is_admin", "password", "created_at" -) = ( -$1, $2, $3 -) WHERE "id" = $4 -` - -type UpdateUserParams struct { - IsAdmin bool `json:"is_admin"` - Password []byte `json:"password"` - CreatedAt time.Time `json:"created_at"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { - _, err := q.db.ExecContext(ctx, updateUser, - arg.IsAdmin, - arg.Password, - arg.CreatedAt, - arg.ID, - ) - return err -} diff --git a/internal/ldap/ldap.go b/internal/ldap/ldap.go deleted file mode 100644 index 8b9f23b..0000000 --- a/internal/ldap/ldap.go +++ /dev/null @@ -1,108 +0,0 @@ -package ldap - -import ( - "fmt" - "regexp" - - ldap "github.com/go-ldap/ldap/v3" - "github.com/pkg/errors" -) - -var ( - // TimeLimitSeconds is the maximal time that LDAP will spend on a single - // request. - TimeLimitSeconds = 5 - // SizeLimitEntries is the biggest number of results that is returned from a - // search request. - SizeLimitEntries = 100 - // UserAttributes is the list of LDAP-Attributes that will be used for user - // accounts. - UserAttributes = []string{ - "dn", // distinguished name, the unique "path" to a LDAP entry. - "cn", // common name, human readable e.g. "Max Powers". - "uid", // user identified, same as the username/login name. - "uidNumber", // unique user ID, integer. - "createTimestamp", // LDAP timestamp of when this entry was created. - "modifyTimestamp", // LDAP timestemp of when this entry was last modified. - } -) - -type Server struct { - Host string - Port int - - bindDN string - bindPW string - - userBaseDN string -} - -func (s *Server) newConn() (*ldap.Conn, error) { - lc, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) - if err != nil { - return nil, errors.Wrap(err, "Failed to dial LDAP") - } - - err = lc.Bind(s.bindDN, s.bindPW) - if err != nil { - return nil, errors.Wrap(err, "Failed to bind service account to LDAP") - } - - return lc, nil -} - -// buildFilterForID builds an LDAP filter that searches for a user with a -// specific uidNumber. -func (s *Server) buildFilterForUserID(id int) string { - return fmt.Sprintf("(&(objectClass=inetOrgPerson)(uidNumber=%d))", id) -} - -func (s *Server) buildFilterForEmail(email string) string { - reg := regexp.MustCompile("[^a-zA-Z0-9-+._@]+") - email = reg.ReplaceAllString(email, "") - return fmt.Sprintf("(&(objectClass=)())") - -// Conn is an LDAP connection. -type Conn struct { - *ldap.Conn -} - -type User struct { - Entry *ldap.Entry -} - -// GetDisplayName implements User interface by returning the display name. -func (u User) GetDisplayName() string { - display := u.Entry.GetAttributeValue("displayName") - - if display == "" { - display = u.Entry.GetAttributeValue("givenName") - } - - if display == "" { - display = u.Entry.GetAttributeValue("cn") - } - - if display == "" { - display = u.GetID() - } - return display -} - -// GetID implements the User interface by returning the user ID. -func (u User) GetID() string { - id := u.Entry.GetAttributeValue("uid") - return id -} - -func (lc *Conn) UserByID(ID string) (User, error) { - ldap.NewSearchRequest() -} - -func (s *Server) UserByEmail(email string) (User, error) { - lc, err := s.newConn() - ldap.NewSearchRequest( - s.userBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - SizeLimitEntries, TimeLimitSeconds, false, - s.buildFilterForEmail(email), UserAttributes, nil) -}