diff --git a/certs/rsa.pub b/certs/rsa.pub deleted file mode 100644 index e197707..0000000 --- a/certs/rsa.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDgVXIw08Wgl5ZTLnnH8o0EiWwvfvqbLxp7OgMsZBWOrJiaSrrNjZOyIab3T5Az/1IoUgjGnKvetiVbNL9HLaHMwaN28Q2RR1z+cw/f3NDFydDYmcj1OA/HT011Xh+K39lOjfQptEMOTCtWLOcuzu21jQICqDgsp7BSu3Lt6ezrHO4+kDSyNjclT9iX+RovjK3snJM1rsstezx1yo+f7NBA5WUs1/PfEgvwDZKBuUIqRb8GGcLEQav6FpNicx/L+I5TNDZgoeSlWYCOjZimQy7VagF7iyBwoqj9htRx3F1gZdjqdJxQzYxFrehKg+j+P/JvIifAx2CWyi5/O9BpSkW7 littlesheep@LittleSheepdeMacBook-Pro.local diff --git a/go.mod b/go.mod index ffb3b18..1099eb3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.0 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.5.0 + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/json-iterator/go v1.1.12 github.com/rs/zerolog v1.31.0 github.com/samber/lo v1.39.0 diff --git a/go.sum b/go.sum index 52b4bf3..023490c 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= diff --git a/pkg/cmd/main.go b/pkg/cmd/main.go index 37283a5..5cab662 100644 --- a/pkg/cmd/main.go +++ b/pkg/cmd/main.go @@ -23,7 +23,7 @@ func main() { viper.AddConfigPath(".") viper.AddConfigPath("..") viper.SetConfigName("settings") - viper.SetConfigType("yaml") + viper.SetConfigType("toml") // Load settings if err := viper.ReadInConfig(); err != nil { diff --git a/pkg/database/migrator.go b/pkg/database/migrator.go index 93a0e47..a2d5bfb 100644 --- a/pkg/database/migrator.go +++ b/pkg/database/migrator.go @@ -13,6 +13,7 @@ func RunMigration(source *gorm.DB) error { &models.AccountContact{}, &models.AuthSession{}, &models.AuthChallenge{}, + &models.MagicToken{}, ); err != nil { return err } diff --git a/pkg/models/accounts.go b/pkg/models/accounts.go index 6060059..6977d06 100644 --- a/pkg/models/accounts.go +++ b/pkg/models/accounts.go @@ -1,6 +1,7 @@ package models import ( + "github.com/samber/lo" "time" "gorm.io/datatypes" @@ -24,10 +25,18 @@ type Account struct { Challenges []AuthChallenge `json:"challenges"` Factors []AuthFactor `json:"factors"` Contacts []AccountContact `json:"contacts"` + MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"` ConfirmedAt *time.Time `json:"confirmed_at"` Permissions datatypes.JSONType[[]string] `json:"permissions"` } +func (v Account) GetPrimaryEmail() AccountContact { + val, _ := lo.Find(v.Contacts, func(item AccountContact) bool { + return item.Type == EmailAccountContact && item.IsPrimary + }) + return val +} + type AccountContactType = int8 const ( diff --git a/pkg/models/auth.go b/pkg/models/auth.go index f8ab4bb..64baa89 100644 --- a/pkg/models/auth.go +++ b/pkg/models/auth.go @@ -18,7 +18,7 @@ type AuthFactor struct { BaseModel Type int8 `json:"type"` - Secret string `json:"secret"` + Secret string `json:"-"` Config JSONMap `json:"config"` AccountID uint `json:"account_id"` } diff --git a/pkg/models/tokens.go b/pkg/models/tokens.go new file mode 100644 index 0000000..9c432a1 --- /dev/null +++ b/pkg/models/tokens.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type MagicTokenType = int8 + +const ( + ConfirmMagicToken = MagicTokenType(iota) + RegistrationMagicToken +) + +type MagicToken struct { + BaseModel + + Code string `json:"code"` + Type int8 `json:"type"` + AssignTo *uint `json:"assign_to"` + ExpiredAt *time.Time `json:"expired_at"` +} diff --git a/pkg/server/accounts_api.go b/pkg/server/accounts_api.go index 53c9a1b..583285f 100644 --- a/pkg/server/accounts_api.go +++ b/pkg/server/accounts_api.go @@ -44,3 +44,19 @@ func doRegister(c *fiber.Ctx) error { return c.JSON(user) } } + +func doRegisterConfirm(c *fiber.Ctx) error { + var data struct { + Code string `json:"code"` + } + + if err := BindAndValidate(c, &data); err != nil { + return err + } + + if err := services.ConfirmAccount(data.Code); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/pkg/server/startup.go b/pkg/server/startup.go index 22a61de..bef1af7 100644 --- a/pkg/server/startup.go +++ b/pkg/server/startup.go @@ -19,10 +19,13 @@ func NewServer() { JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal, }) + A.Get("/.well-known", getMetadata) + api := A.Group("/api").Name("API") { api.Get("/users/me", auth, getPrincipal) api.Post("/users", doRegister) + api.Post("/users/me/confirm", doRegisterConfirm) api.Put("/auth", startChallenge) api.Post("/auth", doChallenge) diff --git a/pkg/server/well_known_api.go b/pkg/server/well_known_api.go new file mode 100644 index 0000000..147123d --- /dev/null +++ b/pkg/server/well_known_api.go @@ -0,0 +1,13 @@ +package server + +import ( + "github.com/gofiber/fiber/v2" + "github.com/spf13/viper" +) + +func getMetadata(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "name": viper.GetString("name"), + "domain": viper.GetString("domain"), + }) +} diff --git a/pkg/services/accounts.go b/pkg/services/accounts.go index d79b85f..436c66a 100644 --- a/pkg/services/accounts.go +++ b/pkg/services/accounts.go @@ -5,7 +5,10 @@ import ( "code.smartsheep.studio/hydrogen/passport/pkg/models" "code.smartsheep.studio/hydrogen/passport/pkg/security" "fmt" + "github.com/samber/lo" "gorm.io/datatypes" + "gorm.io/gorm" + "time" ) func GetAccount(id uint) (models.Account, error) { @@ -65,7 +68,45 @@ func CreateAccount(name, nick, email, password string) (models.Account, error) { if err := database.C.Create(&user).Error; err != nil { return user, err - } else { - return user, nil } + + if tk, err := NewMagicToken(models.ConfirmMagicToken, &user, nil); err != nil { + return user, err + } else if err := NotifyMagicToken(tk); err != nil { + return user, err + } + + return user, nil +} + +func ConfirmAccount(code string) error { + var token models.MagicToken + if err := database.C.Where(&models.MagicToken{ + Code: code, + Type: models.ConfirmMagicToken, + }).First(&token).Error; err != nil { + return err + } else if token.AssignTo == nil { + return fmt.Errorf("account was not found") + } + + var user models.Account + if err := database.C.Where(&models.Account{ + BaseModel: models.BaseModel{ID: *token.AssignTo}, + }).First(&user).Error; err != nil { + return err + } + + return database.C.Transaction(func(tx *gorm.DB) error { + user.ConfirmedAt = lo.ToPtr(time.Now()) + + if err := database.C.Delete(&token).Error; err != nil { + return err + } + if err := database.C.Save(&user).Error; err != nil { + return err + } + + return nil + }) } diff --git a/pkg/services/mailer.go b/pkg/services/mailer.go new file mode 100644 index 0000000..74301fe --- /dev/null +++ b/pkg/services/mailer.go @@ -0,0 +1,51 @@ +package services + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "net/textproto" + + "github.com/jordan-wright/email" + "github.com/spf13/viper" +) + +func SendMail(target string, subject string, content string) error { + mail := &email.Email{ + To: []string{target}, + From: viper.GetString("mailer.name"), + Subject: subject, + Text: []byte(content), + Headers: textproto.MIMEHeader{}, + } + return mail.SendWithTLS( + fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")), + smtp.PlainAuth( + "", + viper.GetString("mailer.username"), + viper.GetString("mailer.password"), + viper.GetString("mailer.smtp_host"), + ), + &tls.Config{ServerName: viper.GetString("mailer.smtp_host")}, + ) +} + +func SendMailHTML(target string, subject string, content string) error { + mail := &email.Email{ + To: []string{target}, + From: viper.GetString("mailer.name"), + Subject: subject, + HTML: []byte(content), + Headers: textproto.MIMEHeader{}, + } + return mail.SendWithTLS( + fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")), + smtp.PlainAuth( + "", + viper.GetString("mailer.username"), + viper.GetString("mailer.password"), + viper.GetString("mailer.smtp_host"), + ), + &tls.Config{ServerName: viper.GetString("mailer.smtp_host")}, + ) +} diff --git a/pkg/services/tokens.go b/pkg/services/tokens.go new file mode 100644 index 0000000..69da356 --- /dev/null +++ b/pkg/services/tokens.go @@ -0,0 +1,63 @@ +package services + +import ( + "code.smartsheep.studio/hydrogen/passport/pkg/database" + "code.smartsheep.studio/hydrogen/passport/pkg/models" + "fmt" + "github.com/google/uuid" + "github.com/spf13/viper" + "strings" + "time" +) + +func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expiredAt *time.Time) (models.MagicToken, error) { + var uid uint + if assignTo != nil { + uid = assignTo.ID + } + + token := models.MagicToken{ + Code: strings.Replace(uuid.NewString(), "-", "", -1), + Type: mode, + AssignTo: &uid, + ExpiredAt: expiredAt, + } + + if err := database.C.Save(&token).Error; err != nil { + return token, err + } else { + return token, nil + } +} + +func NotifyMagicToken(token models.MagicToken) error { + if token.AssignTo == nil { + return fmt.Errorf("could notify a non-assign magic token") + } + + var user models.Account + if err := database.C.Where(&models.MagicToken{ + AssignTo: token.AssignTo, + }).Preload("Contacts").First(&user).Error; err != nil { + return err + } + + var subject string + var content string + switch token.Type { + case models.ConfirmMagicToken: + link := fmt.Sprintf("%s/users/me/confirm?tk=%s", viper.GetString("domain"), token.Code) + subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name")) + content = fmt.Sprintf("We got a create account request with this email recently.\n"+ + "So we need you to click the link below to confirm your registeration.\n"+ + "Confirmnation Link: %s\n"+ + "If you didn't do that, you can ignore this email.\n\n"+ + "%s\n"+ + "Best wishes", + link, viper.GetString("maintainer")) + default: + return fmt.Errorf("unsupported magic token type to notify") + } + + return SendMail(user.GetPrimaryEmail().Content, subject, content) +} diff --git a/settings.toml b/settings.toml new file mode 100644 index 0000000..433206a --- /dev/null +++ b/settings.toml @@ -0,0 +1,12 @@ +debug = true + +name = "Goatpass" +maintainer = "SmartSheep Studio" + +bind = "0.0.0.0:8444" +domain = "id.smartsheep.studio" +secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi" + +[database] +dsn = "host=localhost dbname=hy_passport port=5432 sslmode=disable" +prefix = "passport_" diff --git a/settings.yaml b/settings.yaml deleted file mode 100644 index c708ecd..0000000 --- a/settings.yaml +++ /dev/null @@ -1,9 +0,0 @@ -debug: true - -bind: 0.0.0.0:8444 -domain: id.smartsheep.studio -secret: LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi - -database: - dsn: host=localhost dbname=hy_passport port=5432 sslmode=disable - prefix: passport_ diff --git a/view/src/index.tsx b/view/src/index.tsx index 1d59c90..6bc7487 100644 --- a/view/src/index.tsx +++ b/view/src/index.tsx @@ -5,19 +5,18 @@ import { render } from "solid-js/web"; import "./index.css"; import "./assets/fonts/fonts.css"; +import { lazy } from "solid-js"; import { Route, Router } from "@solidjs/router"; import RootLayout from "./layouts/RootLayout.tsx"; -import DashboardPage from "./pages/dashboard.tsx"; -import LoginPage from "./pages/auth/login.tsx"; -import RegisterPage from "./pages/auth/register.tsx"; const root = document.getElementById("root"); render(() => ( - - - + import("./pages/dashboard.tsx"))} /> + import("./pages/auth/login.tsx"))} /> + import("./pages/auth/register.tsx"))} /> + import("./pages/users/confirm.tsx"))} /> ), root!); diff --git a/view/src/pages/auth/register.tsx b/view/src/pages/auth/register.tsx index 57cbd31..e430a67 100644 --- a/view/src/pages/auth/register.tsx +++ b/view/src/pages/auth/register.tsx @@ -110,6 +110,10 @@ export default function RegisterPage() { + +
+ Already had an account? Login now! +
); diff --git a/view/src/pages/dashboard.tsx b/view/src/pages/dashboard.tsx index f33739a..8f692b2 100644 --- a/view/src/pages/dashboard.tsx +++ b/view/src/pages/dashboard.tsx @@ -1,4 +1,5 @@ import { useUserinfo } from "../stores/userinfo.tsx"; +import { Show } from "solid-js"; export default function DashboardPage() { const userinfo = useUserinfo(); @@ -7,6 +8,22 @@ export default function DashboardPage() {

Welcome, {userinfo?.displayName}

What's a nice day!

+ +
+ + + +
); } \ No newline at end of file diff --git a/view/src/pages/users/confirm.tsx b/view/src/pages/users/confirm.tsx new file mode 100644 index 0000000..82881e6 --- /dev/null +++ b/view/src/pages/users/confirm.tsx @@ -0,0 +1,65 @@ +import { createSignal, Show } from "solid-js"; +import { useSearchParams } from "@solidjs/router"; + +export default function ConfirmRegistrationPage() { + const [error, setError] = createSignal(null); + const [status, setStatus] = createSignal("Confirming your account..."); + + const [searchParams] = useSearchParams(); + + async function doConfirm() { + if (!searchParams["tk"]) { + setError("Bad Request: Code was not exists"); + } + + const res = await fetch("/api/users/me/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: searchParams["tk"] + }) + }); + if (res.status !== 200) { + setError(await res.text()); + } else { + setStatus("Confirmed. Redirecting to dashboard..."); + } + } + + doConfirm(); + + return ( +
+
+
+ + +
+
+
+ +
+ {status()} +
+
+ +
}> +
+ +
+ +
+
+ + ); +} \ No newline at end of file