Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
SQL_SRC_DIR := database
SQL_FILES := $(wildcard $(SQL_SRC_DIR)/{migrations,queries}/*.sql)

all: sqlc

sqlc: $(SQL_FILES)
sqlc generate

build: sqlc
go build ./cmd/lavender
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Lavender

An authentication source for multiple login services to be used with a frontend single page application.
A login service with OpenID, OAuth2 and SSO support.

Login via third-party services.

Enables easy use of a single authentication source for a network of services.
99 changes: 99 additions & 0 deletions auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package auth

import (
"context"
"errors"
"fmt"
"github.com/1f349/lavender/database"
"net/http"
)

type Factor byte

const (
// FactorAuthorized defines the "authorized" state of a session
FactorAuthorized Factor = iota
FactorFirst
FactorSecond
)

type Provider interface {
// Factor defines the factors potentially supported by the provider
// Some factors might be unavailable due to user preference
Factor() Factor

// Name defines a string value for the provider, useful for template switching
Name() string

// RenderData stores values to send to the templating function
RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error

// AttemptLogin processes the login request
AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error
}

var (
// ErrRequiresSecondFactor notifies the ServeHTTP function to ask for another factor
ErrRequiresSecondFactor = errors.New("requires second factor")
// ErrRequiresPreviousFactor is a generic error for providers which require a previous factor
ErrRequiresPreviousFactor = errors.New("requires previous factor")
// ErrUserDoesNotSupportFactor is a generic error for providers with are unable to support the user
ErrUserDoesNotSupportFactor = errors.New("user does not support factor")
)

type UserSafeError struct {
Display string
Code int
Internal error
}

func (e UserSafeError) Error() string {
return fmt.Sprintf("%s [%d]: %v", e.Display, e.Code, e.Internal)
}

func (e UserSafeError) Unwrap() error {
return e.Internal
}

func BasicUserSafeError(code int, message string) UserSafeError {
return UserSafeError{
Code: code,
Display: message,
Internal: errors.New(message),
}
}

func AdminSafeError(inner error) UserSafeError {
return UserSafeError{
Code: http.StatusInternalServerError,
Display: "Internal server error",
Internal: inner,
}
}

type RedirectError struct {
Target string
Code int
}

func (e RedirectError) TargetUrl() string { return e.Target }

func (e RedirectError) Error() string {
return fmt.Sprintf("redirect to '%s'", e.Target)
}

type lookupUserDB interface {
GetUser(ctx context.Context, subject string) (database.User, error)
}

func lookupUser(ctx context.Context, db lookupUserDB, subject string, resolvesTwoFactor bool, user *database.User) error {
getUser, err := db.GetUser(ctx, subject)
if err != nil {
return err
}
*user = getUser
if user.NeedFactor && !resolvesTwoFactor {
return ErrRequiresSecondFactor
}
return nil
}
47 changes: 47 additions & 0 deletions auth/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package auth

import (
"context"
"database/sql"
"errors"
"github.com/1f349/lavender/database"
"net/http"
)

type basicLoginDB interface {
lookupUserDB
CheckLogin(ctx context.Context, un, pw string) (database.CheckLoginResult, error)
}

var _ Provider = (*BasicLogin)(nil)

type BasicLogin struct {
DB basicLoginDB
}

func (b *BasicLogin) Factor() Factor { return FactorFirst }

func (b *BasicLogin) Name() string { return "basic" }

func (b *BasicLogin) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error {
data["username"] = req.FormValue("username")
return nil
}

func (b *BasicLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
un := req.FormValue("username")
pw := req.FormValue("password")
if len(pw) < 8 {
return BasicUserSafeError(http.StatusBadRequest, "Password too short")
}

login, err := b.DB.CheckLogin(ctx, un, pw)
switch {
case err == nil:
return lookupUser(ctx, b.DB, login.Subject, false, user)
case errors.Is(err, sql.ErrNoRows):
return BasicUserSafeError(http.StatusForbidden, "Username or password is invalid")
default:
return err
}
}
96 changes: 96 additions & 0 deletions auth/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package auth

import (
"context"
"fmt"
"github.com/1f349/cache"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/google/uuid"
"golang.org/x/oauth2"
"net/http"
"time"
)

type flowStateData struct {
loginName string
sso *issuer.WellKnownOIDC
redirect string
}

var _ Provider = (*OAuthLogin)(nil)

type OAuthLogin struct {
DB *database.Queries

BaseUrl string

flow *cache.Cache[string, flowStateData]
}

func (o OAuthLogin) Init() {
o.flow = cache.New[string, flowStateData]()
}

func (o OAuthLogin) Factor() Factor { return FactorFirst }

func (o OAuthLogin) Name() string { return "oauth" }

func (o OAuthLogin) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error {
//TODO implement me
panic("implement me")
}

func (o OAuthLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
login, ok := ctx.Value(oauthServiceLogin(0)).(*issuer.WellKnownOIDC)
if !ok {
return fmt.Errorf("missing issuer wellknown")
}
loginName := ctx.Value("login_full").(string)
loginUn := ctx.Value("login_username").(string)

// save state for use later
state := login.Config.Namespace + ":" + uuid.NewString()
o.flow.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute))

// generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config
oa2conf.RedirectURL = o.BaseUrl + "/callback"
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))

return RedirectError{Target: nextUrl, Code: http.StatusFound}
}

func (o OAuthLogin) OAuthCallback(rw http.ResponseWriter, req *http.Request, info func(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error), cookie func(rw http.ResponseWriter, authData UserAuth, loginName string) bool, redirect func(rw http.ResponseWriter, req *http.Request)) {
flowState, ok := o.flow.Get(req.FormValue("state"))
if !ok {
http.Error(rw, "Invalid flow state", http.StatusBadRequest)
return
}
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", o.BaseUrl+"/callback"))
if err != nil {
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
return
}

userAuth, err := info(req, flowState.sso, token)
if err != nil {
http.Error(rw, "Failed to update external user info", http.StatusInternalServerError)
return
}

if cookie(rw, userAuth, flowState.loginName) {
http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError)
return
}
if flowState.redirect != "" {
req.Form.Set("redirect", flowState.redirect)
}
redirect(rw, req)
}

type oauthServiceLogin int

func WithWellKnown(ctx context.Context, login *issuer.WellKnownOIDC) context.Context {
return context.WithValue(ctx, oauthServiceLogin(0), login)
}
85 changes: 85 additions & 0 deletions auth/otp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package auth

import (
"context"
"errors"
"github.com/1f349/lavender/database"
"github.com/xlzd/gotp"
"net/http"
"time"
)

func isDigitsSupported(digits int64) bool {
return digits >= 6 && digits <= 8
}

type otpLoginDB interface {
GetOtp(ctx context.Context, subject string) (database.GetOtpRow, error)
}

var _ Provider = (*OtpLogin)(nil)

type OtpLogin struct {
DB otpLoginDB
}

func (o *OtpLogin) Factor() Factor { return FactorSecond }

func (o *OtpLogin) Name() string { return "basic" }

func (o *OtpLogin) RenderData(_ context.Context, _ *http.Request, user *database.User, data map[string]any) error {
if user == nil || user.Subject == "" {
return ErrRequiresPreviousFactor
}
if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) {
return ErrUserDoesNotSupportFactor
}

// no need to provide render data
return nil
}

func (o *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
if user == nil || user.Subject == "" {
return ErrRequiresPreviousFactor
}
if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) {
return ErrUserDoesNotSupportFactor
}

code := req.FormValue("code")

if !validateTotp(user.OtpSecret, int(user.OtpDigits), code) {
return BasicUserSafeError(http.StatusBadRequest, "invalid OTP code")
}
return nil
}

var ErrInvalidOtpCode = errors.New("invalid OTP code")

func (o *OtpLogin) VerifyOtpCode(ctx context.Context, subject, code string) error {
otp, err := o.DB.GetOtp(ctx, subject)
if err != nil {
return err
}
if !validateTotp(otp.OtpSecret, int(otp.OtpDigits), code) {
return ErrInvalidOtpCode
}
return nil
}

func validateTotp(secret string, digits int, code string) bool {
totp := gotp.NewTOTP(secret, int(digits), 30, nil)
return verifyTotp(totp, code)
}

func verifyTotp(totp *gotp.TOTP, code string) bool {
t := time.Now()
if totp.VerifyTime(code, t) {
return true
}
if totp.VerifyTime(code, t.Add(-30*time.Second)) {
return true
}
return totp.VerifyTime(code, t.Add(30*time.Second))
}
48 changes: 48 additions & 0 deletions auth/passkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package auth

import (
"context"
"github.com/1f349/lavender/database"
"net/http"
)

type passkeyLoginDB interface {
lookupUserDB
}

var _ Provider = (*PasskeyLogin)(nil)

type PasskeyLogin struct {
DB passkeyLoginDB
}

func (p *PasskeyLogin) Factor() Factor { return FactorFirst }

func (p *PasskeyLogin) Name() string { return "passkey" }

func (p *PasskeyLogin) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error {
if user == nil || user.Subject == "" {
return ErrRequiresPreviousFactor
}
if user.OtpSecret == "" {
return ErrUserDoesNotSupportFactor
}

//TODO implement me
panic("implement me")
}

var passkeyShortcut = true

func init() {
passkeyShortcut = true
}

func (p *PasskeyLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
if user.Subject == "" && !passkeyShortcut {
return ErrRequiresPreviousFactor
}

//TODO implement me
panic("implement me")
}
Loading