schema migration and account fixes

very close to rolling this out! just need to address some security concerns first
This commit is contained in:
ari melody 2025-01-20 18:54:03 +00:00
parent 5566a795da
commit 570cdf6ce2
Signed by: ari
GPG key ID: CF99829C92678188
20 changed files with 641 additions and 392 deletions

View file

@ -1,38 +0,0 @@
package admin
import (
"fmt"
"time"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model"
)
type (
Session struct {
Token string
Account *model.Account
Expires time.Time
}
)
const TOKEN_LENGTH = 64
var ADMIN_BYPASS = func() bool {
if global.Args["adminBypass"] == "true" {
fmt.Println("WARN: Admin login is currently BYPASSED. (-adminBypass)")
return true
}
return false
}()
var sessions []*Session
func createSession(account *model.Account, expires time.Time) Session {
return Session{
Token: string(controller.GenerateAlnumString(TOKEN_LENGTH)),
Account: account,
Expires: expires,
}
}

View file

@ -26,8 +26,9 @@ func Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/login", LoginHandler())
mux.Handle("/create-account", createAccountHandler())
mux.Handle("/register", createAccountHandler())
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
// TODO: /admin/account
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist())))
@ -96,7 +97,7 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
account, err := controller.GetAccountByRequest(db, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account == nil {
@ -117,7 +118,7 @@ func LoginHandler() http.Handler {
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account != nil {
@ -141,7 +142,6 @@ func LoginHandler() http.Handler {
err := r.ParseForm()
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error logging in: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
@ -151,21 +151,25 @@ func LoginHandler() http.Handler {
Password string `json:"password"`
TOTP string `json:"totp"`
}
data := LoginRequest{
credentials := LoginRequest{
Username: r.Form.Get("username"),
Password: r.Form.Get("password"),
TOTP: r.Form.Get("totp"),
}
account, err := controller.GetAccount(global.DB, data.Username)
account, err := controller.GetAccount(global.DB, credentials.Username)
if err != nil {
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest)
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword(account.Password, []byte(data.Password))
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "No account exists with this username and password.", http.StatusBadRequest)
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
@ -174,7 +178,7 @@ func LoginHandler() http.Handler {
// login success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -209,24 +213,12 @@ func LogoutHandler() http.Handler {
return
}
token_str := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
tokenStr := controller.GetTokenFromRequest(global.DB, r)
if token_str == "" {
cookie, err := r.Cookie(global.COOKIE_TOKEN)
if len(tokenStr) > 0 {
err := controller.DeleteToken(global.DB, tokenStr)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error fetching token cookie: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if cookie != nil {
token_str = cookie.Value
}
}
if len(token_str) > 0 {
err := controller.DeleteToken(global.DB, token_str)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %s\n", err.Error())
fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -248,9 +240,141 @@ func LogoutHandler() http.Handler {
func createAccountHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := pages["create-account"].Execute(w, TemplateData{})
checkAccount, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
fmt.Printf("Error rendering create account page: %s\n", err)
fmt.Printf("WARN: Failed to fetch account: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if checkAccount != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
type CreateAccountResponse struct {
Account *model.Account
Message string
}
render := func(data CreateAccountResponse) {
err := pages["create-account"].Execute(w, data)
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render(CreateAccountResponse{})
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err = r.ParseForm()
if err != nil {
render(CreateAccountResponse{
Message: "Malformed data.",
})
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure code exists in DB
invite, err := controller.GetInvite(global.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
render(CreateAccountResponse{
Message: "Invalid invite code.",
})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: credentials.Email,
AvatarURL: "/img/default-avatar.png",
}
err = controller.CreateAccount(global.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
render(CreateAccountResponse{
Message: "An account with that username already exists.",
})
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
render(CreateAccountResponse{
Message: "Something went wrong. Please try again.",
})
return
}
err = controller.DeleteInvite(global.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
// registration success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
// gracefully redirect user to login page
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, TemplateData{
Account: &account,
Token: token.Token,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

View file

@ -65,26 +65,23 @@ button:active {
background: #d0d0d0;
border-color: #808080;
}
#error {
background: #ffa9b8;
border: 1px solid #dc5959;
padding: 1em;
border-radius: 4px;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Success}}
<meta http-equiv="refresh" content="5;url=/admin/" />
<p>
{{.Message}}
You should be redirected to <a href="/admin">/admin</a> in 5 seconds.
</p>
{{else}}
{{if .Message}}
<p id="error">{{.Message}}</p>
{{end}}
<form action="/admin/create-account" method="POST" id="create-account">
<form action="/admin/register" method="POST" id="create-account">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="">
@ -101,7 +98,5 @@ button:active {
<button type="submit" class="new">Create Account</button>
</form>
{{end}}
</main>
{{end}}

View file

@ -28,6 +28,10 @@
<div class="nav-item">
<a href="/admin/logout" id="logout">logged in as {{.Account.Username}}. log out</a>
</div>
{{else}}
<div class="nav-item">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav>
</header>

View file

@ -43,6 +43,10 @@ input {
font-family: inherit;
color: inherit;
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
button {
padding: .5em .8em;
@ -89,7 +93,7 @@ button:active {
<input type="password" name="password" value="">
<label for="totp">TOTP</label>
<input type="text" name="totp" value="">
<input type="text" name="totp" value="" disabled>
</div>
<button type="submit" class="save">Login</button>