-
-
+ {{if eq .Mismatch "1"}}
+
Invalid username or password
+ {{else if eq .Mismatch "2"}}
+
Check your inbox for a verification email
+ {{end}}
+ {{if eq .Source "start"}}
+
+
+
+ {{else if eq .Source "password"}}
+
+ {{else if eq .Source "otp"}}
+
+ {{end}}
diff --git a/pages/manage-users-create.go.html b/pages/manage-users-create.go.html
new file mode 100644
index 0000000..d8a0720
--- /dev/null
+++ b/pages/manage-users-create.go.html
@@ -0,0 +1,46 @@
+
+
+
+
{{.ServiceName}}
+
+
+
+
+
+
+
+ Create User
+
+
+
+
diff --git a/pages/remove-otp.go.html b/pages/remove-otp.go.html
new file mode 100644
index 0000000..22f5997
--- /dev/null
+++ b/pages/remove-otp.go.html
@@ -0,0 +1,22 @@
+
+
+
+
{{.ServiceName}}
+
+
+
+
+
+
+
+
+
diff --git a/pages/reset-password.go.html b/pages/reset-password.go.html
new file mode 100644
index 0000000..97dcebe
--- /dev/null
+++ b/pages/reset-password.go.html
@@ -0,0 +1,26 @@
+
+
+
+
{{.ServiceName}}
+
+
+
+
+
+
+
+
+
diff --git a/server/auth.go b/server/auth.go
index 89f0851..d179f62 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -8,36 +8,17 @@ import (
"github.com/1f349/lavender/role"
"github.com/julienschmidt/httprouter"
"net/http"
- "net/url"
- "strings"
)
-type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
-
-type UserAuth struct {
- Subject string
- Factor auth.Factor
- UserInfo auth.UserInfoFields
-}
-
-func (u UserAuth) IsGuest() bool { return u.Subject == "" }
-
-func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL {
- if u.Factor < auth.FactorAuthorized {
- return PrepareRedirectUrl("/login", origin)
- }
- return nil
-}
-
var ErrAuthHttpError = errors.New("auth http error")
-func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
- return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
+func (h *httpServer) RequireAdminAuthentication(next auth.UserHandler) httprouter.Handle {
+ return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, userAuth auth.UserAuth) {
var hasRole bool
if h.DbTx(rw, func(tx *database.Queries) (err error) {
err = tx.UserHasRole(req.Context(), database.UserHasRoleParams{
Role: role.LavenderAdmin,
- Subject: auth.Subject,
+ Subject: userAuth.Subject,
})
switch {
case err == nil:
@@ -54,22 +35,22 @@ func (h *httpServer) RequireAdminAuthentication(next UserHandler) httprouter.Han
http.Error(rw, "403 Forbidden", http.StatusForbidden)
return
}
- next(rw, req, params, auth)
+ next(rw, req, params, userAuth)
})
}
-func (h *httpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
- return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
- if auth.IsGuest() {
- redirectUrl := PrepareRedirectUrl("/login", req.URL)
+func (h *httpServer) RequireAuthentication(next auth.UserHandler) httprouter.Handle {
+ return h.OptionalAuthentication(false, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, userAuth auth.UserAuth) {
+ if userAuth.IsGuest() {
+ redirectUrl := auth.PrepareRedirectUrl("/login", req.URL)
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
return
}
- next(rw, req, params, auth)
+ next(rw, req, params, userAuth)
})
}
-func (h *httpServer) OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle {
+func (h *httpServer) OptionalAuthentication(flowPart bool, next auth.UserHandler) httprouter.Handle {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
authData, err := h.internalAuthenticationHandler(rw, req)
if err != nil {
@@ -86,7 +67,7 @@ func (h *httpServer) OptionalAuthentication(flowPart bool, next UserHandler) htt
}
}
-func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) {
+func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (auth.UserAuth, error) {
// Delete previous login data cookie
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-data",
@@ -96,37 +77,11 @@ func (h *httpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *
SameSite: http.SameSiteLaxMode,
})
- var u UserAuth
+ var u auth.UserAuth
err := h.readLoginAccessCookie(rw, req, &u)
if err != nil {
// not logged in
- return UserAuth{}, nil
+ return auth.UserAuth{}, nil
}
return u, nil
}
-
-func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL {
- // find start of query parameters in target path
- n := strings.IndexByte(targetPath, '?')
- v := url.Values{}
-
- // parse existing query parameters
- if n != -1 {
- q, err := url.ParseQuery(targetPath[n+1:])
- if err != nil {
- panic("PrepareRedirectUrl: invalid hardcoded target path query parameters")
- }
- v = q
- targetPath = targetPath[:n]
- }
-
- // add path of origin as a new query parameter
- orig := origin.Path
- if origin.RawQuery != "" || origin.ForceQuery {
- orig += "?" + origin.RawQuery
- }
- if orig != "" {
- v.Set("redirect", orig)
- }
- return &url.URL{Path: targetPath, RawQuery: v.Encode()}
-}
diff --git a/server/auth_test.go b/server/auth_test.go
index 68b6603..384e90c 100644
--- a/server/auth_test.go
+++ b/server/auth_test.go
@@ -2,6 +2,7 @@ package server
import (
"context"
+ "github.com/1f349/lavender/auth"
"github.com/1f349/mjwt"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
@@ -13,16 +14,16 @@ import (
)
func TestUserAuth_NextFlowUrl(t *testing.T) {
- u := UserAuth{NeedOtp: true}
- assert.Equal(t, url.URL{Path: "/login/otp"}, *u.NextFlowUrl(&url.URL{}))
- assert.Equal(t, url.URL{Path: "/login/otp", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"}))
- assert.Equal(t, url.URL{Path: "/login/otp", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
- u.NeedOtp = false
+ u := auth.UserAuth{Factor: 0}
+ assert.Equal(t, url.URL{Path: "/login"}, *u.NextFlowUrl(&url.URL{}))
+ assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"}))
+ assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
+ u.Factor = auth.FactorAuthorized
assert.Nil(t, u.NextFlowUrl(&url.URL{}))
}
func TestUserAuth_IsGuest(t *testing.T) {
- var u UserAuth
+ var u auth.UserAuth
assert.True(t, u.IsGuest())
u.Subject = uuid.NewString()
assert.False(t, u.IsGuest())
@@ -52,22 +53,22 @@ func TestOptionalAuthentication(t *testing.T) {
rec := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "https://example.com/hello", nil)
assert.NoError(t, err)
- auth, err := h.internalAuthenticationHandler(rec, req)
+ authData, err := h.internalAuthenticationHandler(rec, req)
assert.NoError(t, err)
- assert.True(t, auth.IsGuest())
- auth.Subject = "567"
+ assert.True(t, authData.IsGuest())
+ authData.Subject = "567"
}
func TestPrepareRedirectUrl(t *testing.T) {
- assert.Equal(t, url.URL{Path: "/hello"}, *PrepareRedirectUrl("/hello", &url.URL{}))
- assert.Equal(t, url.URL{Path: "/world"}, *PrepareRedirectUrl("/world", &url.URL{}))
- assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello"}))
- assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
- assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()}))
+ assert.Equal(t, url.URL{Path: "/hello"}, *auth.PrepareRedirectUrl("/hello", &url.URL{}))
+ assert.Equal(t, url.URL{Path: "/world"}, *auth.PrepareRedirectUrl("/world", &url.URL{}))
+ assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *auth.PrepareRedirectUrl("/a", &url.URL{Path: "/hello"}))
+ assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *auth.PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
+ assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"redirect": {"/hello?a=A&b=B"}}.Encode()}, *auth.PrepareRedirectUrl("/a", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()}))
- assert.Equal(t, url.URL{Path: "/hello", RawQuery: "z=y"}, *PrepareRedirectUrl("/hello?z=y", &url.URL{}))
- assert.Equal(t, url.URL{Path: "/world", RawQuery: "z=y"}, *PrepareRedirectUrl("/world?z=y", &url.URL{}))
- assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello"}))
- assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
- assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A&b=B"}}.Encode()}, *PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()}))
+ assert.Equal(t, url.URL{Path: "/hello", RawQuery: "z=y"}, *auth.PrepareRedirectUrl("/hello?z=y", &url.URL{}))
+ assert.Equal(t, url.URL{Path: "/world", RawQuery: "z=y"}, *auth.PrepareRedirectUrl("/world?z=y", &url.URL{}))
+ assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello"}}.Encode()}, *auth.PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello"}))
+ assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A"}}.Encode()}, *auth.PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
+ assert.Equal(t, url.URL{Path: "/a", RawQuery: url.Values{"z": {"y"}, "redirect": {"/hello?a=A&b=B"}}.Encode()}, *auth.PrepareRedirectUrl("/a?z=y", &url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}, "b": {"B"}}.Encode()}))
}
diff --git a/server/edit.go b/server/edit.go
index 981cc0d..30ea342 100644
--- a/server/edit.go
+++ b/server/edit.go
@@ -2,6 +2,7 @@ package server
import (
"fmt"
+ auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/lists"
"github.com/1f349/lavender/pages"
@@ -11,7 +12,7 @@ import (
"time"
)
-func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
var user database.User
if h.DbTx(rw, func(tx *database.Queries) error {
@@ -43,7 +44,7 @@ func (h *httpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httpro
"ListLocale": lists.ListLocale(),
})
}
-func (h *httpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
if req.ParseForm() != nil {
rw.WriteHeader(http.StatusBadRequest)
_, _ = rw.Write([]byte("400 Bad Request\n"))
diff --git a/server/home.go b/server/home.go
index 2b67a64..b3edfce 100644
--- a/server/home.go
+++ b/server/home.go
@@ -1,6 +1,7 @@
package server
import (
+ auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/role"
@@ -10,7 +11,7 @@ import (
"time"
)
-func (h *httpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
rw.Header().Set("Content-Type", "text/html")
lNonce := uuid.NewString()
http.SetCookie(rw, &http.Cookie{
diff --git a/server/login.go b/server/login.go
index 5a55abd..6e64719 100644
--- a/server/login.go
+++ b/server/login.go
@@ -41,7 +41,22 @@ func getUserLoginName(req *http.Request) string {
return originUrl.Query().Get("login_name")
}
-func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) testAuthSources(req *http.Request, user *database.User, factor auth2.Factor) map[string]bool {
+ authSource := make(map[string]bool)
+ data := make(map[string]any)
+ for _, i := range h.authSources {
+ // ignore not-supported factors
+ if i.Factor()&factor == 0 {
+ continue
+ }
+ err := i.RenderData(req.Context(), req, user, data)
+ authSource[i.Name()] = err == nil
+ clear(data)
+ }
+ return authSource
+}
+
+func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
@@ -49,20 +64,41 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil {
+ user, err := h.db.GetUser(req.Context(), auth.Subject)
+ var userPtr *database.User
+ switch {
+ case err == nil:
+ userPtr = &user
+ case errors.Is(err, sql.ErrNoRows):
+ userPtr = nil
+ default:
+ http.Error(rw, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth2.FactorFirst))
+
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
"ServiceName": h.conf.ServiceName,
"LoginName": cookie.Value,
"Redirect": req.URL.Query().Get("redirect"),
+ "Source": "start",
+ "Auth": h.testAuthSources(req, userPtr, auth2.FactorFirst),
})
return
}
+
+ // render different page sources
pages.RenderPageTemplate(rw, "login", map[string]any{
"ServiceName": h.conf.ServiceName,
+ "LoginName": "",
"Redirect": req.URL.Query().Get("redirect"),
+ "Source": "start",
+ "Auth": h.testAuthSources(req, nil, auth2.FactorFirst),
})
}
-func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
@@ -83,15 +119,29 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
return
}
loginName := req.PostFormValue("loginname")
+
+ // append local namespace if @ is missing
+ n := strings.IndexByte(loginName, '@')
+ if n < 0 {
+ // correct the @ index
+ n = len(loginName)
+ loginName += "@" + h.conf.Namespace
+ }
+
login := h.manager.FindServiceFromLogin(loginName)
if login == nil {
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
return
}
+
// the @ must exist if the service is defined
- n := strings.IndexByte(loginName, '@')
loginUn := loginName[:n]
+ ctx := auth2.WithWellKnown(req.Context(), login)
+ ctx = context.WithValue(ctx, "login_username", loginUn)
+ ctx = context.WithValue(ctx, "login_full", loginName)
+
+ // TODO(melon): only do if remember-me is enabled
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
@@ -104,49 +154,36 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
SameSite: http.SameSiteLaxMode,
})
- // save state for use later
- state := login.Config.Namespace + ":" + uuid.NewString()
- h.flowState.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute))
+ var redirectError auth2.RedirectError
- // generate oauth2 config and redirect to authorize URL
- oa2conf := login.OAuth2Config
- oa2conf.RedirectURL = h.conf.BaseUrl + "/callback"
- nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
- http.Redirect(rw, req, nextUrl, http.StatusFound)
+ // if the login is the local server
+ if login == issuer.MeWellKnown {
+ // TODO(melon): work on this
+ err := h.authBasic.AttemptLogin(ctx, req, nil)
+ switch {
+ case errors.As(err, &redirectError):
+ http.Redirect(rw, req, redirectError.Target, redirectError.Code)
+ return
+ }
+ return
+ }
+
+ err := h.authOAuth.AttemptLogin(ctx, req, nil)
+ switch {
+ case errors.As(err, &redirectError):
+ http.Redirect(rw, req, redirectError.Target, redirectError.Code)
+ return
+ }
}
-func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) {
- flowState, ok := h.flowState.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", h.conf.BaseUrl+"/callback"))
- if err != nil {
- http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
- return
- }
-
- userAuth, err = h.updateExternalUserInfo(req, flowState.sso, token)
- if err != nil {
- http.Error(rw, "Failed to update external user info", http.StatusInternalServerError)
- return
- }
-
- if h.setLoginDataCookie(rw, userAuth, flowState.loginName) {
- http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError)
- return
- }
- if flowState.redirect != "" {
- req.Form.Set("redirect", flowState.redirect)
- }
- h.SafeRedirect(rw, req)
+func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth auth2.UserAuth) {
+ h.authOAuth.OAuthCallback(rw, req, h.updateExternalUserInfo, h.setLoginDataCookie, h.SafeRedirect)
}
-func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
+func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) {
sessionData, err := h.fetchUserInfo(sso, token)
if err != nil || sessionData.Subject == "" {
- return UserAuth{}, fmt.Errorf("failed to fetch user info")
+ return auth2.UserAuth{}, fmt.Errorf("failed to fetch user info")
}
// TODO(melon): fix this to use a merging of lavender and tulip auth
@@ -167,9 +204,9 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
err = h.DbTxError(func(tx *database.Queries) error {
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
})
- return UserAuth{
+ return auth2.UserAuth{
Subject: userSubject,
- NeedOtp: sessionData.NeedOtp,
+ Factor: auth2.FactorAuthorized,
UserInfo: sessionData.UserInfo,
}, err
case errors.Is(err, sql.ErrNoRows):
@@ -177,12 +214,12 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
break
default:
// another error occurred
- return UserAuth{}, err
+ return auth2.UserAuth{}, err
}
// guard for disabled registration
if !sso.Config.Registration {
- return UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source")
+ return auth2.UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source")
}
// TODO(melon): rework this
@@ -207,7 +244,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
})
if err != nil {
- return UserAuth{}, err
+ return auth2.UserAuth{}, err
}
// only continues if the above tx succeeds
@@ -219,20 +256,20 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
Subject: sessionData.Subject,
})
}); err != nil {
- return UserAuth{}, err
+ return auth2.UserAuth{}, err
}
// TODO(melon): this feels bad
- sessionData = UserAuth{
+ sessionData = auth2.UserAuth{
Subject: userSubject,
- NeedOtp: sessionData.NeedOtp,
+ Factor: auth2.FactorAuthorized,
UserInfo: sessionData.UserInfo,
}
return sessionData, nil
}
-func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData UserAuth) error {
+func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData auth2.UserAuth) error {
// all of these updates must succeed
return tx.UseTx(ctx, func(tx *database.Queries) error {
name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User")
@@ -274,6 +311,7 @@ const oneWeek = 7 * 24 * time.Hour
type lavenderLoginAccess struct {
UserInfo auth2.UserInfoFields `json:"user_info"`
+ Factor auth2.Factor `json:"factor"`
auth.AccessTokenClaims
}
@@ -290,16 +328,12 @@ func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid(
func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" }
-func (h *httpServer) setLoginDataCookie2(rw http.ResponseWriter, authData UserAuth) bool {
- // TODO(melon): should probably merge these methods
- return h.setLoginDataCookie(rw, authData, "")
-}
-
-func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool {
+func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData auth2.UserAuth, loginName string) bool {
ps := auth.NewPermStorage()
accId := uuid.NewString()
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{
UserInfo: authData.UserInfo,
+ Factor: authData.Factor,
AccessTokenClaims: auth.AccessTokenClaims{Perms: ps},
})
if err != nil {
@@ -346,19 +380,20 @@ func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingK
return b, nil
}
-func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error {
+func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *auth2.UserAuth) error {
loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore())
if err != nil {
return h.readLoginRefreshCookie(rw, req, u)
}
- *u = UserAuth{
+ *u = auth2.UserAuth{
Subject: loginData.Subject,
+ Factor: loginData.Claims.Factor,
UserInfo: loginData.Claims.UserInfo,
}
return nil
}
-func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error {
+func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *auth2.UserAuth) error {
refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore())
if err != nil {
return err
@@ -396,27 +431,28 @@ func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Re
return nil
}
-func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
+func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) {
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
if err != nil || res.StatusCode != http.StatusOK {
- return UserAuth{}, fmt.Errorf("request failed")
+ return auth2.UserAuth{}, fmt.Errorf("request failed")
}
defer res.Body.Close()
var userInfoJson auth2.UserInfoFields
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
- return UserAuth{}, err
+ return auth2.UserAuth{}, err
}
subject, ok := userInfoJson.GetString("sub")
if !ok {
- return UserAuth{}, fmt.Errorf("invalid subject")
+ return auth2.UserAuth{}, fmt.Errorf("invalid subject")
}
// TODO(melon): there is no need for this
//subject += "@" + sso.Config.Namespace
- return UserAuth{
+ return auth2.UserAuth{
Subject: subject,
+ Factor: auth2.FactorAuthorized,
UserInfo: userInfoJson,
}, nil
}
diff --git a/server/logout.go b/server/logout.go
index 1d721d2..46aa7dd 100644
--- a/server/logout.go
+++ b/server/logout.go
@@ -1,11 +1,12 @@
package server
import (
+ auth2 "github.com/1f349/lavender/auth"
"github.com/julienschmidt/httprouter"
"net/http"
)
-func (h *httpServer) logoutPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ UserAuth) {
+func (h *httpServer) logoutPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ auth2.UserAuth) {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-access",
Path: "/",
diff --git a/server/manage-apps.go b/server/manage-apps.go
index 404d46a..a246a40 100644
--- a/server/manage-apps.go
+++ b/server/manage-apps.go
@@ -1,6 +1,7 @@
package server
import (
+ auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/password"
@@ -18,7 +19,7 @@ func SetupManageApps(r *httprouter.Router, hs *httpServer) {
r.POST("/manage/apps", hs.RequireAuthentication(hs.ManageAppsPost))
}
-func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
q := req.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
@@ -66,7 +67,7 @@ func (h *httpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
pages.RenderPageTemplate(rw, "manage-apps", m)
}
-func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
var roles []string
if h.DbTx(rw, func(tx *database.Queries) (err error) {
roles, err = tx.GetUserRoles(req.Context(), auth.Subject)
@@ -85,7 +86,7 @@ func (h *httpServer) ManageAppsCreateGet(rw http.ResponseWriter, req *http.Reque
pages.RenderPageTemplate(rw, "manage-apps-create", m)
}
-func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
err := req.ParseForm()
if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)
diff --git a/server/manage-users.go b/server/manage-users.go
index bf4fa8d..7d243e9 100644
--- a/server/manage-users.go
+++ b/server/manage-users.go
@@ -1,6 +1,7 @@
package server
import (
+ auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/role"
@@ -16,7 +17,7 @@ func SetupManageUsers(r *httprouter.Router, hs *httpServer) {
r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost))
}
-func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
q := req.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
@@ -63,7 +64,7 @@ func (h *httpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
pages.RenderPageTemplate(rw, "manage-users", m)
}
-func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
err := req.ParseForm()
if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)
diff --git a/server/oauth.go b/server/oauth.go
index e79fb4a..445fa41 100644
--- a/server/oauth.go
+++ b/server/oauth.go
@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"fmt"
+ auth2 "github.com/1f349/lavender/auth"
clientStore "github.com/1f349/lavender/client-store"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/logger"
@@ -150,7 +151,7 @@ func (h *httpServer) userInfoRequest(rw http.ResponseWriter, req *http.Request,
_ = json.NewEncoder(rw).Encode(m)
}
-func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
// function is only called with GET or POST method
isPost := req.Method == http.MethodPost
@@ -292,7 +293,7 @@ func (h *httpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re
return "", err
}
- redirectUrl := PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()})
+ redirectUrl := auth2.PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()})
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
return "", nil
}
diff --git a/server/otp.go b/server/otp.go
index 0a7e799..cd38e7e 100644
--- a/server/otp.go
+++ b/server/otp.go
@@ -2,8 +2,8 @@ package server
import (
"bytes"
- "context"
"encoding/base64"
+ auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/julienschmidt/httprouter"
@@ -15,67 +15,7 @@ import (
"time"
)
-func (h *httpServer) loginOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
- if !auth.NeedOtp {
- h.SafeRedirect(rw, req)
- return
- }
-
- pages.RenderPageTemplate(rw, "login-otp", map[string]any{
- "ServiceName": h.conf.ServiceName,
- "Redirect": req.URL.Query().Get("redirect"),
- })
-}
-
-func (h *httpServer) loginOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
- if !auth.NeedOtp {
- http.Redirect(rw, req, "/", http.StatusFound)
- return
- }
-
- otpInput := req.FormValue("code")
- if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) {
- return
- }
-
- auth.NeedOtp = false
-
- h.setLoginDataCookie2(rw, auth)
- h.SafeRedirect(rw, req)
-}
-
-func (h *httpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub, code string) bool {
- var hasOtp bool
- var otpRow database.GetOtpRow
- var secret string
- var digits int64
- if h.DbTx(rw, func(tx *database.Queries) (err error) {
- hasOtp, err = tx.HasOtp(context.Background(), sub)
- if err != nil {
- return
- }
- if hasOtp {
- otpRow, err = tx.GetOtp(context.Background(), sub)
- secret = otpRow.OtpSecret
- digits = otpRow.OtpDigits
- }
- return
- }) {
- return true
- }
-
- if hasOtp {
- totp := gotp.NewTOTP(secret, int(digits), 30, nil)
- if !verifyTotp(totp, code) {
- http.Error(rw, "400 Bad Request: Invalid OTP code", http.StatusBadRequest)
- return true
- }
- }
-
- return false
-}
-
-func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
+func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
if req.Method == http.MethodPost && req.FormValue("remove") == "1" {
if !req.Form.Has("code") {
// render page
@@ -86,7 +26,9 @@ func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
}
otpInput := req.Form.Get("code")
- if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) {
+ err := h.authOtp.VerifyOtpCode(req.Context(), auth.Subject, otpInput)
+ if err != nil {
+ http.Error(rw, "Invalid OTP code", http.StatusBadRequest)
return
}
diff --git a/server/server.go b/server/server.go
index 7e170e6..a1cfa82 100644
--- a/server/server.go
+++ b/server/server.go
@@ -3,6 +3,7 @@ package server
import (
"errors"
"github.com/1f349/cache"
+ "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/conf"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
@@ -30,17 +31,14 @@ type httpServer struct {
signingKey *mjwt.Issuer
manager *issuer.Manager
- // flowState contains the
- flowState *cache.Cache[string, flowStateData]
-
// mailLinkCache contains a mapping of verify uuids to user uuids
mailLinkCache *cache.Cache[mailLinkKey, string]
-}
-type flowStateData struct {
- loginName string
- sso *issuer.WellKnownOIDC
- redirect string
+ authBasic *auth.BasicLogin
+ authOtp *auth.OtpLogin
+ authOAuth *auth.OAuthLogin
+
+ authSources []auth.Provider
}
type mailLink byte
@@ -62,19 +60,32 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, s
contentCache := time.Now()
+ authBasic := &auth.BasicLogin{DB: db}
+ authOtp := &auth.OtpLogin{DB: db}
+ authOAuth := &auth.OAuthLogin{DB: db, BaseUrl: config.BaseUrl}
+ authOAuth.Init()
+
hs := &httpServer{
r: r,
db: db,
conf: config,
signingKey: signingKey,
- flowState: cache.New[string, flowStateData](),
-
mailLinkCache: cache.New[mailLinkKey, string](),
+
+ authBasic: authBasic,
+ authOtp: authOtp,
+ authOAuth: authOAuth,
+ //authPasskey: &auth.PasskeyLogin{DB: db},
+
+ authSources: []auth.Provider{
+ authBasic,
+ authOtp,
+ },
}
var err error
- hs.manager, err = issuer.NewManager(config.SsoServices)
+ hs.manager, err = issuer.NewManager(config.Namespace, config.SsoServices)
if err != nil {
logger.Logger.Fatal("Failed to load SSO services", "err", err)
}
@@ -97,8 +108,6 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, db *database.Queries, s
// login steps
r.GET("/login", hs.OptionalAuthentication(false, hs.loginGet))
r.POST("/login", hs.OptionalAuthentication(false, hs.loginPost))
- r.GET("/login/otp", hs.OptionalAuthentication(true, hs.loginOtpGet))
- r.POST("/login/otp", hs.OptionalAuthentication(true, hs.loginOtpPost))
r.GET("/callback", hs.OptionalAuthentication(false, hs.loginCallback))
SetupManageApps(r, hs)