Add email smtp sending and account registration
ci/woodpecker/push/build Pipeline failed Details

This commit is contained in:
Melon 2022-11-21 17:55:24 +00:00
parent 88b4f0379c
commit cf84d16876
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
12 changed files with 700 additions and 404 deletions

29
.editorconfig Normal file
View File

@ -0,0 +1,29 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Defaults
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
# CSS
[*.go]
indent_size = 2
indent_style = space
# HTML
[*.{htm,html}]
indent_size = 2
indent_style = space
# GNU make
[Makefile]
indent_style = tab
# YAML
[*.{yaml,yml}]
indent_size = 2
indent_style = space

View File

@ -1,12 +1,15 @@
package main
import accountServer "code.mrmelon54.com/melon/summer/pkg/account-server"
type MarigoldConfig struct {
Database string `yaml:"database"`
Listen string `yaml:"listen"`
Auth ApiAuthConfig `yaml:"auth"`
Database string `yaml:"database"`
Listen string `yaml:"listen"`
Auth AuthConfig `yaml:"auth"`
Smtp accountServer.SmtpConfig `yaml:"smtp"`
}
type ApiAuthConfig struct {
type AuthConfig struct {
Issuer string `yaml:"issuer"`
Key string `yaml:"key"`
Public string `yaml:"public"`

View File

@ -59,6 +59,7 @@ func (marigold *Marigold) Init(runner *cli.Runner) {
quickUser.QuickUser(runner.Database)
var privKey *rsa.PrivateKey
fmt.Println(marigold.conf.Auth.Key)
file, err := os.ReadFile(marigold.conf.Auth.Key)
if os.IsNotExist(err) {
privKey = generateNewKeys(marigold.conf.Auth)
@ -76,7 +77,7 @@ func (marigold *Marigold) Init(runner *cli.Runner) {
marigold.checkLogin = loginChecker.NewLoginChecker(runner.Database, marigold.signer)
router := mux.NewRouter()
marigold.auth = accountServer.NewAccountServer(router.PathPrefix("/auth").Subrouter(), runner.Database, marigold.signer)
marigold.auth = accountServer.NewAccountServer(router.PathPrefix("/auth").Subrouter(), runner.Database, marigold.signer, marigold.conf.Smtp)
marigold.oauth = oauthServer.NewOAuthServer(router.PathPrefix("/oauth").Subrouter(), runner.Database, marigold.signer, marigold.conf.Auth.AuthClient)
crud.NewCrudHandler[user.User](router, marigold.signer, accountServer.NewUserProvider(runner.Database), "/users", "/user/{id}")
crud.NewCrudHandler[oauth.App](router, marigold.signer, oauthServer.NewOAuthAppProvider(runner.Database), "/apps", "/app/{id}")
@ -97,7 +98,7 @@ func (marigold *Marigold) Destroy() {
_ = marigold.server.Close()
}
func generateNewKeys(auth ApiAuthConfig) *rsa.PrivateKey {
func generateNewKeys(auth AuthConfig) *rsa.PrivateKey {
// Generate key
fmt.Println("[Marigold.Init()] Generating new RSA private key")
key, err := rsa.GenerateKey(rand.New(rand.NewSource(time.Now().UnixNano())), 4096)

2
go.mod
View File

@ -4,6 +4,8 @@ go 1.19
require (
code.mrmelon54.com/melon/certgen v0.0.0-20220830133534-0fb4cb7e67d1
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0
github.com/go-acme/lego/v4 v4.8.0
github.com/go-oauth2/oauth2/v4 v4.5.1
github.com/go-sql-driver/mysql v1.6.0

4
go.sum
View File

@ -97,6 +97,10 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{.Title}</title>
<style>
#app > h1 {
text-decoration: underline;
}
@media screen and (prefers-color-scheme: dark) {
:root {
color: #d2d2d2;
background-color: #1c1b22;
}
}
</style>
</head>
<body>
<div id="app">
<h1>{.Title}</h1>
<p>Hi {.Name},</p>
<p>Here is your email verification code: <span>{.Code}</span></p>
</div>
</body>
</html>

View File

@ -32,3 +32,17 @@ type MfaFlowInput struct {
Digits int `json:"digits"`
Mfa string `json:"mfa"`
}
type RegisterFlowInput struct {
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
RepeatPassword string `json:"repeatPassword"`
}
type EmailTemplateStruct struct {
Title string
Name string
Code string
}

View File

@ -1,486 +1,595 @@
package account_server
import (
"bytes"
"code.mrmelon54.com/melon/summer/pkg/api"
"code.mrmelon54.com/melon/summer/pkg/mjwt"
"code.mrmelon54.com/melon/summer/pkg/mjwt/auth"
"code.mrmelon54.com/melon/summer/pkg/mjwt/flow"
"code.mrmelon54.com/melon/summer/pkg/tables/user"
"code.mrmelon54.com/melon/summer/pkg/utils"
"crypto"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/sec51/twofactor"
"net/http"
"regexp"
"sync"
"time"
"xorm.io/xorm"
"bytes"
"code.mrmelon54.com/melon/summer/pkg/api"
"code.mrmelon54.com/melon/summer/pkg/mjwt"
"code.mrmelon54.com/melon/summer/pkg/mjwt/auth"
"code.mrmelon54.com/melon/summer/pkg/mjwt/flow"
"code.mrmelon54.com/melon/summer/pkg/tables/user"
"code.mrmelon54.com/melon/summer/pkg/utils"
"crypto"
"crypto/subtle"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/sec51/twofactor"
"html/template"
"net/http"
"regexp"
"sync"
"time"
"xorm.io/xorm"
)
var (
emailPattern = regexp.MustCompile("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,}$")
mfaCodePattern = regexp.MustCompile("^\\d{6,8}$")
emailCodePattern = regexp.MustCompile("^\\d{10}$")
emailPattern = regexp.MustCompile("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,}$")
usernamePattern = regexp.MustCompile("^[\\w-_.]{3,32}$")
mfaCodePattern = regexp.MustCompile("^\\d{6,8}$")
emailCodePattern = regexp.MustCompile("^\\d{10}$")
//go:embed email-template.go.html
emailTemplate string
)
type AccountServer struct {
signer mjwt.Provider
router *mux.Router
db *xorm.Engine
flows map[string]*LoginFlow
mFlow *sync.RWMutex
signer mjwt.Provider
router *mux.Router
db *xorm.Engine
flows map[string]*LoginFlow
mFlow *sync.RWMutex
smtp SmtpConfig
}
func NewAccountServer(router *mux.Router, db *xorm.Engine, signer mjwt.Provider) *AccountServer {
s := &AccountServer{
signer: signer,
router: router,
db: db,
flows: make(map[string]*LoginFlow),
mFlow: &sync.RWMutex{},
}
router.HandleFunc("/login", s.handlePostLogin).Methods(http.MethodPost)
router.HandleFunc("/refresh", s.handlePostRefresh).Methods(http.MethodPost)
router.HandleFunc("/password-reset", s.handlePostPasswordReset).Methods(http.MethodPost)
router.HandleFunc("/password-email", s.handlePostPasswordEmail).Methods(http.MethodPost)
router.HandleFunc("/mfa", s.handleMfaCreate).Methods(http.MethodPost)
router.HandleFunc("/mfa-disable", s.handleMfaDisable).Methods(http.MethodPost)
return s
func NewAccountServer(router *mux.Router, db *xorm.Engine, signer mjwt.Provider, smtpConfig SmtpConfig) *AccountServer {
s := &AccountServer{
signer: signer,
router: router,
db: db,
flows: make(map[string]*LoginFlow),
mFlow: &sync.RWMutex{},
smtp: smtpConfig,
}
router.HandleFunc("/login", s.handlePostLogin).Methods(http.MethodPost)
router.HandleFunc("/refresh", s.handlePostRefresh).Methods(http.MethodPost)
router.HandleFunc("/password-reset", s.handlePostPasswordReset).Methods(http.MethodPost)
router.HandleFunc("/password-email", s.handlePostPasswordEmail).Methods(http.MethodPost)
router.HandleFunc("/mfa", s.handleMfaCreate).Methods(http.MethodPost)
router.HandleFunc("/mfa-disable", s.handleMfaDisable).Methods(http.MethodPost)
router.HandleFunc("/register", s.handlePostRegister).Methods(http.MethodPost)
return s
}
func (a *AccountServer) LoadMfaTotp(u *user.User) (*twofactor.Totp, error) {
if u.MfaObj != nil {
return u.MfaObj, nil
}
otp, err := twofactor.TOTPFromBytes(u.MfaBytes, a.signer.Issuer())
u.MfaObj = otp
return u.MfaObj, err
if u.MfaObj != nil {
return u.MfaObj, nil
}
otp, err := twofactor.TOTPFromBytes(u.MfaBytes, a.signer.Issuer())
u.MfaObj = otp
return u.MfaObj, err
}
func (a *AccountServer) outputUserTokens(rw http.ResponseWriter, u *user.User) {
// Get perms list
var perms []user.ExtendUserPerm
err := a.db.Table(&user.LinkUserPerm{}).Where("user_id = ?", u.Id).Join("INNER", &user.Perm{}, "link_user_perm.perm_id = perm.id").Find(&perms)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Internal Server Error") {
return
}
// Get perms list
var perms []user.ExtendUserPerm
err := a.db.Table(&user.LinkUserPerm{}).Where("user_id = ?", u.Id).Join("INNER", &user.Perm{}, "link_user_perm.perm_id = perm.id").Find(&perms)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Internal Server Error") {
return
}
storage := mjwt.NewPermStorage()
for _, i := range perms {
storage.Set(i.Perm.Name)
}
storage := mjwt.NewPermStorage()
for _, i := range perms {
storage.Set(i.Perm.Name)
}
// Generate access and refresh tokens
accessToken, err := auth.CreateAccessToken(a.signer, u.Email, uuid.NewString(), u.Id, storage)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
refreshToken, err := auth.CreateRefreshToken(a.signer, u.Email, uuid.NewString(), u.Id)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
// Generate access and refresh tokens
accessToken, err := auth.CreateAccessToken(a.signer, u.Email, uuid.NewString(), u.Id, storage)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
refreshToken, err := auth.CreateRefreshToken(a.signer, u.Email, uuid.NewString(), u.Id)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
// Respond
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(rw).Encode(LoginSuccessResp{
AccessToken: accessToken,
RefreshToken: refreshToken,
})
// Respond
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(rw).Encode(LoginSuccessResp{
AccessToken: accessToken,
RefreshToken: refreshToken,
})
}
func (a *AccountServer) outputFlowStep(rw http.ResponseWriter, sub, nextStep string, loginId uint64) {
flowToken, err := flow.CreateLoginFlowToken(a.signer, sub, uuid.NewString(), nextStep, loginId, time.Minute*10)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_ = json.NewEncoder(rw).Encode(LoginFlowResp{Next: nextStep, Token: flowToken})
flowToken, err := flow.CreateLoginFlowToken(a.signer, sub, uuid.NewString(), nextStep, loginId, time.Minute*10)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_ = json.NewEncoder(rw).Encode(LoginFlowResp{Next: nextStep, Token: flowToken})
}
func (a *AccountServer) handlePostLogin(rw http.ResponseWriter, req *http.Request) {
token := utils.GetBearerToken(req)
if token == "" {
u := uuid.NewString()
f := &LoginFlow{User: nil}
a.mFlow.Lock()
a.flows[u] = f
a.mFlow.Unlock()
a.outputFlowStep(rw, u, "login", 0)
return
}
token := utils.GetBearerToken(req)
if token == "" {
u := uuid.NewString()
f := &LoginFlow{User: nil}
a.mFlow.Lock()
a.flows[u] = f
a.mFlow.Unlock()
a.outputFlowStep(rw, u, "login", 0)
return
}
// Verify mjwt
_, b, err := mjwt.ExtractClaims[flow.LoginFlowClaims](a.signer, token)
if err != nil {
// Verify as access token
_, z, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
// Verify mjwt
_, b, err := mjwt.ExtractClaims[flow.LoginFlowClaims](a.signer, token)
if err != nil {
// Verify as access token
_, z, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
// The request was sent to verify an existing access token and was successful
a.outputFlowStep(rw, z.ID, "done", z.Claims.UserId)
return
}
// The request was sent to verify an existing access token and was successful
a.outputFlowStep(rw, z.ID, "done", z.Claims.UserId)
return
}
// Fetch flow user
a.mFlow.RLock()
u, ok := a.flows[b.Subject]
a.mFlow.RUnlock()
if !ok {
if api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusForbidden, "Login Process Not Started") {
return
}
return
}
// Fetch flow user
a.mFlow.RLock()
u, ok := a.flows[b.Subject]
a.mFlow.RUnlock()
if !ok {
if api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusForbidden, "Login Process Not Started") {
return
}
return
}
// Get input
var in LoginFlowInput
err = json.NewDecoder(req.Body).Decode(&in)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to decode JSON input") {
return
}
// Get input
var in LoginFlowInput
err = json.NewDecoder(req.Body).Decode(&in)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to decode JSON input") {
return
}
// Handle next state
switch b.Claims.NextState {
case "login":
// Check email matches pattern
if !emailPattern.MatchString(in.Email) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid email input")
return
}
// Check password matches pattern
if !ValidatePassword(in.Password) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid password input")
return
}
// Handle next state
switch b.Claims.NextState {
case "login":
// Check email matches pattern
if !emailPattern.MatchString(in.Email) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid email input")
return
}
// Check password matches pattern
if !ValidatePassword(in.Password) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid password input")
return
}
// Find matching emails
var users []user.User
err := a.db.Where("email = ?", in.Email).Find(&users)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Internal Server Error") {
return
}
// Find matching emails
var users []user.User
err := a.db.Where("email = ?", in.Email).Find(&users)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Internal Server Error") {
return
}
// Find specific user
if len(users) < 1 {
if api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Login Invalid") {
return
}
return
}
if len(users) > 1 {
if api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Internal Server Error") {
return
}
return
}
u.User = &users[0]
// Find specific user
if len(users) < 1 {
if api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Login Invalid") {
return
}
return
}
if len(users) > 1 {
if api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Internal Server Error") {
return
}
return
}
u.User = &users[0]
// Check password
err = CheckPasswordHash(u.User.Password, in.Password)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Login Invalid") {
return
}
// Check password
err = CheckPasswordHash(u.User.Password, in.Password)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Login Invalid") {
return
}
// Check email
if !utils.SBool(u.User.EmailVerified) {
a.outputFlowStep(rw, b.Subject, "email", u.User.Id)
return
}
// Check email
if !utils.SBool(u.User.EmailVerified) {
a.outputFlowStep(rw, b.Subject, "email", u.User.Id)
return
}
// Check MFA
if utils.SBool(u.User.MfaActive) {
a.outputFlowStep(rw, b.Subject, "mfa", u.User.Id)
return
}
// Check MFA
if utils.SBool(u.User.MfaActive) {
a.outputFlowStep(rw, b.Subject, "mfa", u.User.Id)
return
}
// Success
a.outputUserTokens(rw, u.User)
case "mfa":
// Check MFA code matches pattern
if !mfaCodePattern.MatchString(in.Mfa) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid Email Code")
return
}
// Success
a.outputUserTokens(rw, u.User)
case "mfa":
// Check MFA code matches pattern
if !mfaCodePattern.MatchString(in.Mfa) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid Email Code")
return
}
// Check if user is undefined
if u.User == nil {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid login flow state")
return
}
// Check if user is undefined
if u.User == nil {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid login flow state")
return
}
// Load TOTP to check MFA code
totp, err := a.LoadMfaTotp(u.User)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to load multi-factor auth handler") {
return
}
err = totp.Validate(in.Mfa)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid MFA code") {
return
}
// Load TOTP to check MFA code
totp, err := a.LoadMfaTotp(u.User)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to load multi-factor auth handler") {
return
}
err = totp.Validate(in.Mfa)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid MFA code") {
return
}
// Success
a.outputUserTokens(rw, u.User)
case "email":
// Check email code matches pattern
if !emailCodePattern.MatchString(in.Code) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid Email Code")
return
}
// Success
a.outputUserTokens(rw, u.User)
case "email":
// Check email code matches pattern
if !emailCodePattern.MatchString(in.Code) {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid Email Code")
return
}
// Check if user is undefined
if u.User == nil {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid login flow state")
return
}
// Check if user is undefined
if u.User == nil {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid login flow state")
return
}
var zzz user.User
get, err := a.db.Where("id = ?", u.User.Id).Get(&zzz)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
if !get {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusUnauthorized, "Token Not Valid")
return
}
var zzz user.User
get, err := a.db.Where("id = ?", u.User.Id).Get(&zzz)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
if !get {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusUnauthorized, "Token Not Valid")
return
}
// Check email code
if subtle.ConstantTimeCompare([]byte(zzz.EmailToken), []byte(in.Code)) == 0 {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid email code")
return
}
// Check email code
if subtle.ConstantTimeCompare([]byte(zzz.EmailToken), []byte(in.Code)) == 0 {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid email code")
return
}
_, err = a.db.Where("id = ?", zzz.Id).Update(&user.User{EmailVerified: utils.PBool(true)})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to validate email, maybe it is already validated") {
return
}
_, err = a.db.Where("id = ?", zzz.Id).Update(&user.User{EmailVerified: utils.PBool(true)})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to validate email, maybe it is already validated") {
return
}
// Check MFA active
if utils.SBool(u.User.MfaActive) {
a.outputFlowStep(rw, b.Subject, "mfa", u.User.Id)
return
}
// Check MFA active
if utils.SBool(u.User.MfaActive) {
a.outputFlowStep(rw, b.Subject, "mfa", u.User.Id)
return
}
// Success
a.outputUserTokens(rw, u.User)
default:
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid login flow state")
}
// Success
a.outputUserTokens(rw, u.User)
default:
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid login flow state")
}
}
func (a *AccountServer) handlePostRefresh(rw http.ResponseWriter, req *http.Request) {
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Refresh token missing")
return
}
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Refresh token missing")
return
}
// Verify mjwt
_, b, err := mjwt.ExtractClaims[auth.RefreshTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
// Verify mjwt
_, b, err := mjwt.ExtractClaims[auth.RefreshTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
var u user.User
get, err := a.db.Where("id = ?", b.Claims.UserId).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
if !get {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusUnauthorized, "Token Not Valid")
return
}
var u user.User
get, err := a.db.Where("id = ?", b.Claims.UserId).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
if !get {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusUnauthorized, "Token Not Valid")
return
}
a.outputUserTokens(rw, &u)
a.outputUserTokens(rw, &u)
}
func (a *AccountServer) handlePostPasswordReset(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusNotImplemented)
rw.WriteHeader(http.StatusNotImplemented)
}
func (a *AccountServer) handlePostPasswordEmail(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusNotImplemented)
rw.WriteHeader(http.StatusNotImplemented)
}
func (a *AccountServer) handleMfaCreate(rw http.ResponseWriter, req *http.Request) {
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Access token missing")
return
}
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Access token missing")
return
}
// Get input
var in MfaFlowInput
err := json.NewDecoder(req.Body).Decode(&in)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to decode JSON input") {
return
}
// Get input
var in MfaFlowInput
err := json.NewDecoder(req.Body).Decode(&in)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to decode JSON input") {
return
}
// Verify as mfa flow token
_, b, err := mjwt.ExtractClaims[flow.MfaFlowClaims](a.signer, token)
if err != nil {
// Verify as access token
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
// Verify as mfa flow token
_, b, err := mjwt.ExtractClaims[flow.MfaFlowClaims](a.signer, token)
if err != nil {
// Verify as access token
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
// Check email code matches pattern
if in.Digits < 6 || in.Digits > 8 {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid Number of Digits")
return
}
// Check email code matches pattern
if in.Digits < 6 || in.Digits > 8 {
api.GenericErrorMsg[AccountServer](rw, api.ErrParamWrongType, http.StatusBadRequest, "Invalid Number of Digits")
return
}
var u user.User
ok, err := a.db.Where("id = ? and mfa_active = ?", b.Claims.UserId, false).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User") {
return
}
if !ok {
api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User or MFA is already active")
return
}
var u user.User
ok, err := a.db.Where("id = ? and mfa_active = ?", b.Claims.UserId, false).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User") {
return
}
if !ok {
api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User or MFA is already active")
return
}
totp, err := twofactor.NewTOTP(u.Email, a.signer.Issuer(), crypto.SHA512, in.Digits)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to generate MFA validator") {
return
}
totpBytes, err := totp.ToBytes()
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to save MFA validator") {
return
}
totp, err := twofactor.NewTOTP(u.Email, a.signer.Issuer(), crypto.SHA512, in.Digits)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to generate MFA validator") {
return
}
totpBytes, err := totp.ToBytes()
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to save MFA validator") {
return
}
_, err = a.db.Where("id = ?", b.Claims.UserId).Update(&user.User{MfaBytes: totpBytes})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to update MFA validator") {
return
}
_, err = a.db.Where("id = ?", b.Claims.UserId).Update(&user.User{MfaBytes: totpBytes})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to update MFA validator") {
return
}
qr, err := totp.QR()
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to generate QR code") {
return
}
buf := new(bytes.Buffer)
_, _ = base64.NewEncoder(base64.StdEncoding, buf).Write(qr)
qr, err := totp.QR()
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to generate QR code") {
return
}
buf := new(bytes.Buffer)
_, _ = base64.NewEncoder(base64.StdEncoding, buf).Write(qr)
flowToken, err := flow.CreateMfaFlowToken(a.signer, b.Subject, uuid.NewString(), u.Id, time.Minute*10)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_ = json.NewEncoder(rw).Encode(MfaFlowResp{Qr: "data:image/png;base64," + buf.String(), Token: flowToken})
return
}
flowToken, err := flow.CreateMfaFlowToken(a.signer, b.Subject, uuid.NewString(), u.Id, time.Minute*10)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Token Signing Error") {
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_ = json.NewEncoder(rw).Encode(MfaFlowResp{Qr: "data:image/png;base64," + buf.String(), Token: flowToken})
return
}
var u user.User
ok, err := a.db.Where("id = ? and mfa_active = ?", b.Claims.LoginId, false).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User") {
return
}
if !ok {
api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User or MFA is already active")
return
}
var u user.User
ok, err := a.db.Where("id = ? and mfa_active = ?", b.Claims.LoginId, false).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User") {
return
}
if !ok {
api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User or MFA is already active")
return
}
totp, err := a.LoadMfaTotp(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to load MFA validator") {
return
}
totp, err := a.LoadMfaTotp(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to load MFA validator") {
return
}
err = totp.Validate(in.Mfa)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to validate MFA") {
return
}
err = totp.Validate(in.Mfa)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to validate MFA") {
return
}
_, err = a.db.Where("id = ?", b.Claims.LoginId).Update(&user.User{MfaActive: utils.PBool(true)})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to update MFA validator") {
return
}
_, err = a.db.Where("id = ?", b.Claims.LoginId).Update(&user.User{MfaActive: utils.PBool(true)})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to update MFA validator") {
return
}
rw.WriteHeader(http.StatusCreated)
rw.WriteHeader(http.StatusCreated)
}
func (a *AccountServer) handleMfaDisable(rw http.ResponseWriter, req *http.Request) {
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Access token missing")
return
}
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Access token missing")
return
}
// Verify as access token
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
// Verify as access token
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Token Not Valid") {
return
}
var u user.User
ok, err := a.db.Where("id = ? and mfa_active = ?", b.Claims.UserId, false).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User") {
return
}
if !ok {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Invalid User or MFA is already active")
return
}
var u user.User
ok, err := a.db.Where("id = ? and mfa_active = ?", b.Claims.UserId, false).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Invalid User") {
return
}
if !ok {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Invalid User or MFA is already active")
return
}
_, err = a.db.Where("id = ? and mfa_active = ?", b.Claims.UserId, true).Update(&user.User{MfaActive: utils.PBool(false)})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to disable MFA, maybe it is already disabled") {
return
}
_, err = a.db.Where("id = ? and mfa_active = ?", b.Claims.UserId, true).Update(&user.User{MfaActive: utils.PBool(false)})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Failed to disable MFA, maybe it is already disabled") {
return
}
rw.WriteHeader(http.StatusNoContent)
rw.WriteHeader(http.StatusNoContent)
}
func (a *AccountServer) handleGetUserInfo(rw http.ResponseWriter, req *http.Request) {
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Access token missing")
return
}
token := utils.GetBearerToken(req)
if token == "" {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusInternalServerError, "Access token missing")
return
}
// Verify as access token
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid Token") {
return
}
// Verify as access token
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](a.signer, token)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid Token") {
return
}
z := user.CheckSpecificPerms(b.Claims.Perms, []string{"super:user", "user:info", "user:email", "user:profile"})
if z[0] {
z[1] = true
z[2] = true
z[3] = true
}
y := make(map[string]any)
z := user.CheckSpecificPerms(b.Claims.Perms, []string{"super:user", "user:info", "user:email", "user:profile"})
if z[0] {
z[1] = true
z[2] = true
z[3] = true
}
y := make(map[string]any)
var u user.User
ok, err := a.db.Where("id = ?", b.Claims.UserId).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid User") {
return
}
if ok {
if z[1] {
y["id"] = u.Id
y["visibility"] = user.ParseVisibility(u.Visibility)
y["sub"] = u.Username
}
if z[2] {
y["email_verified"] = utils.SBool(u.EmailVerified)
y["email"] = u.Email
}
if z[3] {
y["gender"] = utils.SString(u.Gender)
y["pronouns"] = user.ParsePronouns(u.Pronouns)
y["birthday"] = u.Birthday.String()
}
} else {
api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid User")
}
var u user.User
ok, err := a.db.Where("id = ?", b.Claims.UserId).Get(&u)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid User") {
return
}
if ok {
if z[1] {
y["id"] = u.Id
y["visibility"] = user.ParseVisibility(u.Visibility)
y["sub"] = u.Username
}
if z[2] {
y["email_verified"] = utils.SBool(u.EmailVerified)
y["email"] = u.Email
}
if z[3] {
y["gender"] = utils.SString(u.Gender)
y["pronouns"] = user.ParsePronouns(u.Pronouns)
y["birthday"] = u.Birthday.String()
}
} else {
api.GenericErrorMsg[AccountServer](rw, err, http.StatusUnauthorized, "Invalid User")
}
}
func (a *AccountServer) handlePostRegister(rw http.ResponseWriter, req *http.Request) {
// Get input
var in RegisterFlowInput
err := json.NewDecoder(req.Body).Decode(&in)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusBadRequest, "Failed to decode JSON input") {
return
}
if !emailPattern.MatchString(in.Email) {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid Email")
return
}
if !usernamePattern.MatchString(in.Username) {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid Username")
return
}
if !ValidatePassword(in.Password) {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid Password")
return
}
if in.Password != in.RepeatPassword {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid Repeat Password")
return
}
count, err := a.db.Where("username = ?", in.Username).Count(&user.User{})
if err != nil {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid Username")
return
}
if count != 0 {
api.GenericErrorMsg[AccountServer](rw, api.ErrNoError, http.StatusBadRequest, "Invalid Username")
return
}
n := time.Now()
hashedPw, err := HashPassword(in.Password)
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Password Hashing Error") {
return
}
_, err = a.db.Insert(&user.User{
Id: 0,
Email: in.Email,
Username: in.Username,
Password: hashedPw,
Visibility: user.ProfileVisibilityPrivate.AsByte(),
Gender: nil,
Pronouns: user.ProfilePronounsUnknown.AsByte(),
Birthday: time.UnixMilli(-1),
DisplayName: in.DisplayName,
Created: n,
LastOnline: n,
IconId: 0,
MfaActive: nil,
MfaBytes: nil,
MfaObj: nil,
EmailVerified: utils.PBool(false),
EmailToken: "",
EnableRefresh: utils.PBool(false),
Enabled: utils.PBool(true),
})
if api.GenericErrorMsg[AccountServer](rw, err, http.StatusInternalServerError, "Database Error") {
return
}
rw.WriteHeader(http.StatusCreated)
}
func (a *AccountServer) sendEmailCheck(name, email, code string) error {
smtpAuth := sasl.NewPlainClient("", a.smtp.Username, a.smtp.Password)
parse, err := template.New("email").Parse(emailTemplate)
if err != nil {
return err
}
buf := new(bytes.Buffer)
_, _ = fmt.Fprintf(buf, "To: %s\r\n", email)
_, _ = fmt.Fprintf(buf, "Subject: %s\r\n", "Register Account Email")
buf.WriteString("MIME-version: 1.0;\r\n")
buf.WriteString("Content-Type: text/html; charset=\"UTF-8\";\r\n\r\n")
err = parse.Execute(buf, EmailTemplateStruct{
Title: "Register Account Email",
Name: name,
Code: code,
})
if err != nil {
return err
}
return smtp.SendMail(a.smtp.Server, smtpAuth, a.smtp.Username, []string{email}, buf)
}

View File

@ -0,0 +1,7 @@
package account_server
type SmtpConfig struct {
Server string `yaml:"server"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}

View File

@ -49,6 +49,7 @@ func Run(software, dbString string, executor Executor) {
func DecodeConfig[T any](software string) (t T) {
f := getConfigPath(software)
fmt.Println(f)
open, err := os.Open(f)
utils.Check("Failed to open config file", err)
decoder := yaml.NewDecoder(open)

View File

@ -22,6 +22,10 @@ func ParseVisibility(a *byte) ProfileVisibility {
return b
}
func (v ProfileVisibility) AsByte() *byte {
return utils.PByte(byte(v))
}
type ProfilePronouns byte
const (
@ -39,6 +43,10 @@ func ParsePronouns(a *byte) ProfilePronouns {
return b
}
func (p ProfilePronouns) AsByte() *byte {
return utils.PByte(byte(p))
}
type User struct {
Id uint64 `json:"id" xorm:"pk autoincr"`
Email string `json:"email"`
@ -49,6 +57,7 @@ type User struct {
Pronouns *byte `json:"pronouns" xorm:"unsigned tinyint(3)"`
Birthday time.Time `json:"birthday"`
DisplayName string `json:"display_name"`
Created time.Time `json:"created"`
LastOnline time.Time `json:"last_online"`
IconId uint64 `json:"icon"`
MfaActive *bool `json:"-"`

View File

@ -108,6 +108,65 @@ paths:
$ref: "base.yml#/components/responses/Forb"
429:
$ref: "base.yml#/components/responses/TooMany"
/auth/register:
post:
summary: Register for an account.
operationId: authRegister
tags:
- Register
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/RegisterInput"
examples:
'Example':
value:
email: 'jane@example.com'
username: 'jane'
displayName: 'Jane Doe'
password: 'mySecure123$$'
repeatPassword: 'mySecure123$$'
'No Display Name':
value:
email: 'jane@example.com'
username: 'jane'
password: 'mySecure123$$'
repeatPassword: 'mySecure123$$'
responses:
202:
$ref: "#/components/responses/RegisterTokenResp"
400:
description: Register Error
content:
application/json:
schema:
$ref: "base.yml#/components/schemas/Error"
examples:
'Bad Request':
value:
message: Bad Request
log_id: LOG_fd07bab0-e7e3-4c60-9587-b53b5d2b6e72
'Invalid Email':
description: Email is invalid. Maybe banned or already used?
value:
message: 'Invalid Email'
log_id: LOG_897d37b0-67ba-4229-ba9d-0992980bd529
'Invalid Username':
description: Username is invalid. Maybe banned or already used?
value:
message: 'Invalid Username'
log_id: LOG_c1ddcbd7-1a3e-473d-a941-578d5aeed7c7
'Invalid Password':
description: Password is invalid. Maybe not complicated enough?
value:
message: 'Invalid Password'
log_id: LOG_5e5e0a4a-528d-4a6d-9ab6-4d4884539c0a
'Invalid Repeat Password':
description: Repeat Password doesn't match the password field
value:
message: 'Invalid Repeat Password'
log_id: LOG_fc921d80-f4e8-40cb-be07-ed175249c7e2
/auth/mfa:
post:
summary: Setup Multi-factor Authentication
@ -384,6 +443,18 @@ components:
value:
access_token: 'abcd1234'
refresh_token: 'efgh5678'
RegisterTokenResp:
description: Register
content:
application/json:
schema:
$ref: "#/components/schemas/LoginToken"
examples:
'Example':
description: Returns access and refresh tokens
value:
access_token: 'abcd1234'
refresh_token: 'efgh5678'
schemas:
LoginInput:
type: object
@ -446,6 +517,25 @@ components:
type: string
description: MFA response code to check setup was successful
pattern: /^\d{6,8}$/
RegisterInput:
type: object
properties:
email:
type: string
pattern: /^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$/
username:
type: string
pattern: /^[a-z-_\.]+$/
minLength: 3
maxLength: 32
displayName:
description: If blank this will default to the username
type: string
maxLength: 32
password:
type: string
repeatPassword:
type: string
OAuthFlowInput:
type: object
properties: