From ae254dd731a7b0ce0581ab3699e97c9c09ed7029 Mon Sep 17 00:00:00 2001 From: ari melody Date: Mon, 20 Jan 2025 23:49:54 +0000 Subject: [PATCH] totp codes don't seem to sync but they're here!! --- admin/views/edit-account.html | 63 +++++++++++ admin/views/login.html | 6 +- controller/totp.go | 108 ++++++++++++++++++ main.go | 144 +++++++++++++++++++++++- model/totp.go | 12 ++ schema_migration/000-init.sql | 20 ++-- schema_migration/001-pre-versioning.sql | 19 ++-- 7 files changed, 345 insertions(+), 27 deletions(-) create mode 100644 admin/views/edit-account.html create mode 100644 controller/totp.go create mode 100644 model/totp.go diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html new file mode 100644 index 0000000..4096d60 --- /dev/null +++ b/admin/views/edit-account.html @@ -0,0 +1,63 @@ +{{define "head"}} +Account Settings - ari melody 💫 + + +{{end}} + +{{define "content"}} +
+

Account Settings ({{.Account.Username}})

+ +
+

Change Password

+ +
+
+ + + + + + + + +
+ + +
+
+ +
+

MFA Devices

+
+
+ {{if .TOTPs}} + {{range .TOTPs}} +
+

{{.Name}}

+

{{.CreatedAt}}

+
+ {{end}} + {{else}} +

You have no MFA devices.

+ {{end}} + + Add MFA Device +
+ +
+

Danger Zone

+
+
+

+ Clicking the button below will delete your account. + This action is irreversible. + You will be prompted to confirm this decision. +

+ +
+ +
+ + +{{end}} diff --git a/admin/views/login.html b/admin/views/login.html index 16c0fcc..f7edc9b 100644 --- a/admin/views/login.html +++ b/admin/views/login.html @@ -87,13 +87,13 @@ button:active {
- + - + - +
diff --git a/controller/totp.go b/controller/totp.go new file mode 100644 index 0000000..008634b --- /dev/null +++ b/controller/totp.go @@ -0,0 +1,108 @@ +package controller + +import ( + "arimelody-web/model" + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "fmt" + "math" + "net/url" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +const TIME_STEP int64 = 30 +const CODE_LENGTH = 6 + +func GenerateTOTP(secret string, timeStepOffset int) string { + counter := time.Now().Unix() / TIME_STEP - int64(timeStepOffset) + counterBytes := make([]byte, 8) + binary.BigEndian.PutUint64(counterBytes, uint64(counter)) + + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write(counterBytes) + hash := mac.Sum(nil) + + offset := hash[len(hash) - 1] & 0x0f + binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) + code := binaryCode % int32(math.Pow10(CODE_LENGTH)) + + return fmt.Sprintf(fmt.Sprintf("%%0%dd", CODE_LENGTH), code) +} + +func GenerateTOTPURI(username string, secret string) string { + url := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username), + } + + query := url.Query() + query.Set("secret", secret) + query.Set("issuer", "arimelody.me") + query.Set("algorithm", "SHA1") + query.Set("digits", fmt.Sprintf("%d", CODE_LENGTH)) + query.Set("period", fmt.Sprintf("%d", TIME_STEP)) + url.RawQuery = query.Encode() + + return url.String() +} + +func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) { + totps := []model.TOTP{} + + err := db.Select( + &totps, + "SELECT * FROM totp " + + "WHERE account=$1 " + + "ORDER BY created_at ASC", + accountID, + ) + if err != nil { + return nil, err + } + + return totps, nil +} + +func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) { + totp := model.TOTP{} + + err := db.Get( + &totp, + "SELECT * FROM totp " + + "WHERE account=$1", + accountID, + ) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &totp, nil +} + +func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error { + _, err := db.Exec( + "INSERT INTO totp (account, name, secret) " + + "VALUES ($1,$2,$3)", + totp.AccountID, + totp.Name, + totp.Secret, + ) + return err +} + +func DeleteTOTP(db *sqlx.DB, accountID string, name string) error { + _, err := db.Exec( + "DELETE FROM totp WHERE account=$1 AND name=$2", + accountID, + name, + ) + return err +} diff --git a/main.go b/main.go index fb83494..aa3cff6 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "arimelody-web/api" "arimelody-web/controller" "arimelody-web/global" + "arimelody-web/model" "arimelody-web/templates" "arimelody-web/view" @@ -30,10 +31,6 @@ func main() { fmt.Printf("made with <3 by ari melody\n\n") // initialise database connection - if env := os.Getenv("ARIMELODY_DB_HOST"); env != "" { global.Config.DB.Host = env } - if env := os.Getenv("ARIMELODY_DB_NAME"); env != "" { global.Config.DB.Name = env } - if env := os.Getenv("ARIMELODY_DB_USER"); env != "" { global.Config.DB.User = env } - if env := os.Getenv("ARIMELODY_DB_PASS"); env != "" { global.Config.DB.Pass = env } if global.Config.DB.Host == "" { fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") os.Exit(1) @@ -76,6 +73,136 @@ func main() { arg := os.Args[1] switch arg { + case "createTOTP": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + totpName := os.Args[3] + secret := controller.GenerateAlnumString(32) + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + totp := model.TOTP { + AccountID: account.ID, + Name: totpName, + Secret: string(secret), + } + + err = controller.CreateTOTP(global.DB, &totp) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) + os.Exit(1) + } + + url := controller.GenerateTOTPURI(account.Username, totp.Secret) + fmt.Printf("%s\n", url) + return + + case "deleteTOTP": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + totpName := os.Args[3] + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + err = controller.DeleteTOTP(global.DB, account.ID, totpName) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create TOTP method: %v\n", err) + os.Exit(1) + } + + fmt.Printf("TOTP method \"%s\" deleted.\n", totpName) + return + + case "listTOTP": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + totps, err := controller.GetTOTPsForAccount(global.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create TOTP methods: %v\n", err) + os.Exit(1) + } + + for i, totp := range totps { + fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) + } + if len(totps) == 0 { + fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username) + } + return + + case "testTOTP": + if len(os.Args) < 4 { + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n") + os.Exit(1) + } + username := os.Args[2] + totpName := os.Args[3] + + account, err := controller.GetAccount(global.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) + } + + if account == nil { + fmt.Fprintf(os.Stderr, "Account \"%s\" does not exist.\n", username) + os.Exit(1) + } + + totp, err := controller.GetTOTP(global.DB, account.ID, totpName) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to fetch TOTP method \"%s\": %v\n", totpName, err) + os.Exit(1) + } + + if totp == nil { + fmt.Fprintf(os.Stderr, "TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) + os.Exit(1) + } + + code := controller.GenerateTOTP(totp.Secret, 0) + fmt.Printf("%s\n", code) + return + case "createInvite": fmt.Printf("Creating invite...\n") invite, err := controller.CreateInvite(global.DB, 16, time.Hour * 24) @@ -120,8 +247,8 @@ func main() { return case "deleteAccount": - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "FATAL: Account name not specified for -deleteAccount\n") + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") os.Exit(1) } username := os.Args[2] @@ -159,6 +286,11 @@ func main() { // command help fmt.Print( "Available commands:\n\n" + + "createTOTP :\n\tCreates a timed one-time passcode method.\n" + + "listTOTP :\n\tLists an account's TOTP methods.\n" + + "deleteTOTP :\n\tDeletes an account's TOTP method.\n" + + "testTOTP :\n\tGenerates the code for an account's TOTP method.\n" + + "\n" + "createInvite:\n\tCreates an invite code to register new accounts.\n" + "purgeInvites:\n\tDeletes all available invite codes.\n" + "listAccounts:\n\tLists all active accounts.\n", diff --git a/model/totp.go b/model/totp.go new file mode 100644 index 0000000..8d8422f --- /dev/null +++ b/model/totp.go @@ -0,0 +1,12 @@ +package model + +import ( + "time" +) + +type TOTP struct { + Name string `json:"name" db:"name"` + AccountID string `json:"accountID" db:"account"` + Secret string `json:"-" db:"secret"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/schema_migration/000-init.sql b/schema_migration/000-init.sql index 00a7eb2..2c6e5b1 100644 --- a/schema_migration/000-init.sql +++ b/schema_migration/000-init.sql @@ -28,14 +28,6 @@ CREATE TABLE arimelody.privilege ( ); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); --- TOTP -CREATE TABLE arimelody.totp ( - account uuid NOT NULL, - name text NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); - -- Invites CREATE TABLE arimelody.invite ( code text NOT NULL, @@ -54,6 +46,16 @@ CREATE TABLE arimelody.token ( ); ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); +-- TOTPs +CREATE TABLE arimelody.totp ( + name TEXT NOT NULL, + account UUID NOT NULL, + secret TEXT, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + + -- Artists (should be applicable to all art) CREATE TABLE arimelody.artist ( @@ -122,8 +124,8 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM -- ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; diff --git a/schema_migration/001-pre-versioning.sql b/schema_migration/001-pre-versioning.sql index 8f5e210..62bc15b 100644 --- a/schema_migration/001-pre-versioning.sql +++ b/schema_migration/001-pre-versioning.sql @@ -34,14 +34,6 @@ CREATE TABLE arimelody.privilege ( ); ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account, privilege); --- TOTP -CREATE TABLE arimelody.totp ( - account uuid NOT NULL, - name text NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); - -- Invites CREATE TABLE arimelody.invite ( code text NOT NULL, @@ -60,7 +52,16 @@ CREATE TABLE arimelody.token ( ); ALTER TABLE arimelody.token ADD CONSTRAINT token_pk PRIMARY KEY (token); +-- TOTPs +CREATE TABLE arimelody.totp ( + name TEXT NOT NULL, + account UUID NOT NULL, + secret TEXT, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name); + -- Foreign keys ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; -ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE; +ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;