diff --git a/pkg/gordio/auth/apikey.go b/pkg/gordio/auth/apikey.go new file mode 100644 index 0000000..20885bb --- /dev/null +++ b/pkg/gordio/auth/apikey.go @@ -0,0 +1,37 @@ +package auth + +import ( + "context" + "time" + + "dynatron.me/x/stillbox/pkg/gordio/database" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +func (a *Authenticator) CheckAPIKey(ctx context.Context, key string) (*database.ApiKey, error) { + keyUuid, err := uuid.Parse(key) + if err != nil { + log.Error().Str("apikey", key).Msg("cannot parse key") + return nil, ErrBadRequest + } + + db := database.FromCtx(ctx) + apik, err := db.GetAPIKey(ctx, keyUuid) + if err != nil { + if database.IsNoRows(err) { + log.Error().Str("apikey", keyUuid.String()).Msg("no such key") + return nil, ErrUnauthorized + } + + return nil, ErrInternal + } + + if (apik.Disabled != nil && *apik.Disabled) || (apik.Expires.Valid && time.Now().After(apik.Expires.Time)) { + log.Error().Str("key", apik.ApiKey.String()).Msg("key disabled") + return nil, ErrUnauthorized + } + + return &apik, nil +} diff --git a/pkg/gordio/server/auth.go b/pkg/gordio/auth/auth.go similarity index 100% rename from pkg/gordio/server/auth.go rename to pkg/gordio/auth/auth.go diff --git a/pkg/gordio/auth/jwt.go b/pkg/gordio/auth/jwt.go new file mode 100644 index 0000000..4463aa7 --- /dev/null +++ b/pkg/gordio/auth/jwt.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "golang.org/x/crypto/bcrypt" + "net/http" + "time" + + "dynatron.me/x/stillbox/pkg/gordio/database" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" + "github.com/go-chi/render" + "github.com/rs/zerolog/log" +) + + +type claims map[string]interface{} + +func (a *Authenticator) Authenticated(r *http.Request) (claims, bool) { + // TODO: check IP against ACL, or conf.Public, and against map of routes + tok, cl, err := jwtauth.FromContext(r.Context()) + return cl, err != nil && tok != nil +} + +func (a *Authenticator) InstallVerifyMiddleware(r chi.Router) { + r.Use(jwtauth.Verifier(a.jwt)) +} + +func (a *Authenticator) InstallAuthMiddleware(r chi.Router) { + r.Use(jwtauth.Authenticator(a.jwt)) +} + +func (a *Authenticator) Login(ctx context.Context, username, password string) (token string, err error) { + q := database.New(database.FromCtx(ctx)) + users, err := q.GetUsers(ctx) + if err != nil { + log.Error().Err(err).Msg("getUsers failed") + return "", ErrLoginFailed + } + + var found *database.User + + for _, u := range users { + if u.Username == username { + found = &u + } + } + + if found == nil { + _ = bcrypt.CompareHashAndPassword([]byte("lol@timing"), []byte(password)) + return "", ErrLoginFailed + } else { + err = bcrypt.CompareHashAndPassword([]byte(found.Password), []byte(password)) + if err != nil { + return "", ErrLoginFailed + } + } + + return a.NewToken(found.ID), nil +} + +func (a *Authenticator) NewToken(uid int32) string { + claims := claims{ + "user_id": uid, + } + jwtauth.SetExpiryIn(claims, time.Hour*24*30) // one month + _, tokenString, err := a.jwt.Encode(claims) + if err != nil { + panic(err) + } + return tokenString +} + +func (a *Authenticator) InstallRoutes(r chi.Router) { + r.Post("/auth", a.routeAuth) +} + +func (a *Authenticator) routeAuth(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + username, password := r.PostFormValue("username"), r.PostFormValue("password") + if username == "" || password == "" { + http.Error(w, "blank credentials", http.StatusBadRequest) + return + } + + tok, err := a.Login(r.Context(), username, password) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: tok, + HttpOnly: true, + Secure: true, + Domain: a.domain, + }) + + jr := struct { + JWT string `json:"jwt"` + }{ + JWT: tok, + } + + render.JSON(w, r, &jr) +}