go-ssb-room/roomdb/sqlite/auth_fallback.go

234 lines
6.9 KiB
Go

// SPDX-FileCopyrightText: 2021 The NGI Pointer Secure-Scuttlebutt Team of 2020/2021
//
// SPDX-License-Identifier: MIT
package sqlite
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"github.com/friendsofgo/errors"
"github.com/mattn/go-sqlite3"
"github.com/volatiletech/sqlboiler/v4/boil"
"github.com/volatiletech/sqlboiler/v4/queries/qm"
"go.mindeco.de/http/auth"
"golang.org/x/crypto/bcrypt"
"github.com/ssbc/go-ssb-room/v2/roomdb"
"github.com/ssbc/go-ssb-room/v2/roomdb/sqlite/models"
weberrors "github.com/ssbc/go-ssb-room/v2/web/errors"
)
// compiler assertion to ensure the struct fullfills the interface
var _ roomdb.AuthFallbackService = (*AuthFallback)(nil)
type AuthFallback struct {
db *sql.DB
}
var redirectPasswordAuthErr = weberrors.ErrRedirect{
Path: "/fallback/login",
Reason: auth.ErrBadLogin,
}
// Check receives the loging and password (in clear) and checks them accordingly.
// Login might be a registered alias or a ssb id who belongs to a member.
// If it's a valid combination it returns the user ID, or an error if they are not.
func (af AuthFallback) Check(login, password string) (interface{}, error) {
ctx := context.Background()
var memberID = int64(-1)
// try looking up an alias first
loginIsAlias, err := models.Aliases(qm.Where("name = ?", login)).One(ctx, af.db)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
// something else went wrong
return nil, err
}
// did not find an alias, try as ssb reference
member, err := models.Members(qm.Where("pub_key = ?", login)).One(ctx, af.db)
if errors.Is(err, sql.ErrNoRows) {
return nil, redirectPasswordAuthErr
}
// member found by ssb id, use their id
memberID = member.ID
} else {
// found an alias, use the corresponding member ID
memberID = loginIsAlias.MemberID
}
foundPassword, err := models.FallbackPasswords(qm.Where("member_id = ?", memberID)).One(ctx, af.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, redirectPasswordAuthErr
}
return nil, err
}
err = bcrypt.CompareHashAndPassword(foundPassword.PasswordHash, []byte(password))
if err != nil {
return nil, redirectPasswordAuthErr
}
return foundPassword.MemberID, nil
}
func (af AuthFallback) SetPassword(ctx context.Context, memberID int64, password string) error {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("auth/fallback: failed to hash password for member")
}
// this is a silly upsert construction which sqlboiler-sqlite doesnt support nativly
return transact(af.db, func(tx *sql.Tx) error {
foundPassword, err := models.FallbackPasswords(qm.Where("member_id = ?", memberID)).One(ctx, tx)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
// something else went wrong
return err
}
// not found => insert new entry
var newPasswordEntry models.FallbackPassword
newPasswordEntry.PasswordHash = hashed
newPasswordEntry.MemberID = memberID
err = newPasswordEntry.Insert(ctx, tx, boil.Infer())
if err != nil {
return fmt.Errorf("auth/fallback: failed to insert new user: %w", err)
}
} else {
// found => update the entry
foundPassword.PasswordHash = hashed
_, err = foundPassword.Update(ctx, tx, boil.Whitelist("password_hash"))
if err != nil {
return fmt.Errorf("auth/fallback: failed to update password for member: %w", err)
}
}
return nil
})
}
func (af AuthFallback) SetPasswordWithToken(ctx context.Context, resetToken string, password string) error {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("auth/fallback: failed to hash password for member")
}
hashedTok, err := getHashedToken(resetToken)
if err != nil {
return fmt.Errorf("invalid password reset token")
}
// this is a silly upsert construction which sqlboiler-sqlite doesnt support nativly
return transact(af.db, func(tx *sql.Tx) error {
// make sure its a valid one and load it
resetEntry, err := models.FallbackResetTokens(qm.Where("active = true and hashed_token = ?", hashedTok)).One(ctx, tx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("could not find the reset-token")
}
return err
}
// see that there is a password entry for the member in the reset entry
foundPassword, err := models.FallbackPasswords(qm.Where("member_id = ?", resetEntry.ForMember)).One(ctx, tx)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
// not found => insert new entry
var newPasswordEntry models.FallbackPassword
newPasswordEntry.PasswordHash = hashed
newPasswordEntry.MemberID = resetEntry.ForMember
err = newPasswordEntry.Insert(ctx, tx, boil.Infer())
if err != nil {
return fmt.Errorf("auth/fallback: failed to insert new fallback password for member: %w", err)
}
} else {
// found it => update the entry
foundPassword.PasswordHash = hashed
_, err = foundPassword.Update(ctx, tx, boil.Whitelist("password_hash"))
if err != nil {
return fmt.Errorf("auth/fallback: failed to update password for member: %w", err)
}
}
// finally, invalidate the token
resetEntry.Active = false
_, err = resetEntry.Update(ctx, tx, boil.Whitelist("active"))
if err != nil {
return fmt.Errorf("auth/fallback: failed to invalidate the reset entry: %w", err)
}
return nil
})
}
func (af AuthFallback) CreateResetToken(ctx context.Context, createdByMember, forMember int64) (string, error) {
var newResetToken = models.FallbackResetToken{
CreatedBy: createdByMember,
ForMember: forMember,
}
tokenBytes := make([]byte, inviteTokenLength)
err := transact(af.db, func(tx *sql.Tx) error {
inserted := false
trying:
for tries := 100; tries > 0; tries-- {
// generate an invite code
rand.Read(tokenBytes)
// hash the binary of the token for storage
h := sha256.New()
h.Write(tokenBytes)
newResetToken.HashedToken = fmt.Sprintf("%x", h.Sum(nil))
// insert the new invite
err := newResetToken.Insert(ctx, tx, boil.Infer())
if err != nil {
var sqlErr sqlite3.Error
if errors.As(err, &sqlErr) && sqlErr.ExtendedCode == sqlite3.ErrConstraintUnique {
// generated an existing token, retry
continue trying
}
return err
}
inserted = true
break // no error means it worked!
}
if !inserted {
return errors.New("admindb: failed to generate an invite token in a reasonable amount of time")
}
return nil
})
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(tokenBytes), nil
}
// since reset tokens are marked as invalid so that the code can't be generated twice,
// they need to be deleted periodically.
func deleteConsumedResetTokens(tx boil.ContextExecutor) error {
_, err := models.FallbackResetTokens(qm.Where("active = false")).DeleteAll(context.Background(), tx)
if err != nil {
return fmt.Errorf("admindb: failed to delete used reset tokens: %w", err)
}
return nil
}