mirror of
https://github.com/1f349/lavender.git
synced 2024-12-22 07:34:06 +00:00
Start coding lavender
This commit is contained in:
parent
8a63c2c06d
commit
7d9df44ec7
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
*.sqlite
|
||||||
|
*.local
|
||||||
|
.idea/
|
||||||
|
.data/
|
11
cmd/lavender/conf.go
Normal file
11
cmd/lavender/conf.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import loginServiceManager "github.com/1f349/lavender/issuer"
|
||||||
|
|
||||||
|
type startUpConfig struct {
|
||||||
|
Listen string `json:"listen"`
|
||||||
|
BaseUrl string `json:"base_url"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
SsoServices []loginServiceManager.SsoConfig `json:"sso_services"`
|
||||||
|
}
|
19
cmd/lavender/main.go
Normal file
19
cmd/lavender/main.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
subcommands.Register(subcommands.HelpCommand(), "")
|
||||||
|
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||||
|
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||||
|
subcommands.Register(&serveCmd{}, "")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
ctx := context.Background()
|
||||||
|
os.Exit(int(subcommands.Execute(ctx)))
|
||||||
|
}
|
88
cmd/lavender/serve.go
Normal file
88
cmd/lavender/serve.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"github.com/1f349/lavender/issuer"
|
||||||
|
"github.com/1f349/lavender/server"
|
||||||
|
"github.com/1f349/violet/utils"
|
||||||
|
exit_reload "github.com/MrMelon54/exit-reload"
|
||||||
|
"github.com/MrMelon54/mjwt"
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serveCmd struct{ configPath string }
|
||||||
|
|
||||||
|
func (s *serveCmd) Name() string { return "serve" }
|
||||||
|
|
||||||
|
func (s *serveCmd) Synopsis() string { return "Serve API authentication service" }
|
||||||
|
|
||||||
|
func (s *serveCmd) SetFlags(f *flag.FlagSet) {
|
||||||
|
f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serveCmd) Usage() string {
|
||||||
|
return `serve [-conf <config file>]
|
||||||
|
Serve API authentication service using information from the config file
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
|
log.Println("[Lavender] Starting...")
|
||||||
|
|
||||||
|
if s.configPath == "" {
|
||||||
|
log.Println("[Lavender] Error: config flag is missing")
|
||||||
|
return subcommands.ExitUsageError
|
||||||
|
}
|
||||||
|
|
||||||
|
openConf, err := os.Open(s.configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Println("[Lavender] Error: missing config file")
|
||||||
|
} else {
|
||||||
|
log.Println("[Lavender] Error: open config file: ", err)
|
||||||
|
}
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
var config startUpConfig
|
||||||
|
err = json.NewDecoder(openConf).Decode(&config)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("[Lavender] Error: invalid config file: ", err)
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
configPathAbs, err := filepath.Abs(s.configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[Lavender] Failed to get absolute config path")
|
||||||
|
}
|
||||||
|
wd := filepath.Dir(configPathAbs)
|
||||||
|
normalLoad(config, wd)
|
||||||
|
return subcommands.ExitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalLoad(startUp startUpConfig, wd string) {
|
||||||
|
mSign, err := mjwt.NewMJwtSignerFromFileOrCreate(startUp.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[Lavender] Failed to load or create MJWT signer:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager, err := issuer.NewManager(startUp.SsoServices)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[Lavender] Failed to create SSO service manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.NewHttpServer(startUp.Listen, startUp.BaseUrl, manager, mSign)
|
||||||
|
log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Addr)
|
||||||
|
go utils.RunBackgroundHttp("HTTP", srv)
|
||||||
|
|
||||||
|
exit_reload.ExitReload("Tulip", func() {}, func() {
|
||||||
|
// stop http server
|
||||||
|
_ = srv.Close()
|
||||||
|
})
|
||||||
|
}
|
13
go.mod
13
go.mod
@ -1,3 +1,16 @@
|
|||||||
module github.com/1f349/lavender
|
module github.com/1f349/lavender
|
||||||
|
|
||||||
go 1.20
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/1f349/violet v0.0.10
|
||||||
|
github.com/MrMelon54/exit-reload v0.0.1
|
||||||
|
github.com/MrMelon54/mjwt v0.1.1
|
||||||
|
github.com/google/subcommands v1.2.0
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
)
|
||||||
|
18
go.sum
Normal file
18
go.sum
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
github.com/1f349/violet v0.0.10 h1:2HuQq7SddV60JZ4Xr7DmmhTOPbjiF+1Uqk+d6O1f18U=
|
||||||
|
github.com/1f349/violet v0.0.10/go.mod h1:Uzu6I1pLBP5UEzcUCTQBbk/NTfI5TAABSrowa8DSpR0=
|
||||||
|
github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc=
|
||||||
|
github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug=
|
||||||
|
github.com/MrMelon54/mjwt v0.1.1 h1:m+aTpxbhQCrOPKHN170DQMFR5r938LkviU38unob5Jw=
|
||||||
|
github.com/MrMelon54/mjwt v0.1.1/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
29
issuer/manager.go
Normal file
29
issuer/manager.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package issuer
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
m map[string]*WellKnownOIDC
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(services []SsoConfig) (*Manager, error) {
|
||||||
|
l := &Manager{m: make(map[string]*WellKnownOIDC)}
|
||||||
|
for _, i := range services {
|
||||||
|
conf, err := i.FetchConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// save by issuer
|
||||||
|
l.m[conf.Issuer] = conf
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Manager) CheckIssuer(issuer string) bool {
|
||||||
|
_, ok := l.m[issuer]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
|
||||||
|
|
||||||
|
return l.m[namespace]
|
||||||
|
}
|
77
issuer/sso.go
Normal file
77
issuer/sso.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package issuer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SsoConfig is the base URL for an OAUTH/OPENID/SSO login service
|
||||||
|
// The path `/.well-known/openid-configuration` should be available
|
||||||
|
type SsoConfig struct {
|
||||||
|
Addr string `json:"addr"` // https://login.example.com
|
||||||
|
Namespace string `json:"namespace"` // example.com
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) {
|
||||||
|
confUrl := path.Join(s.Addr, ".well-known", "openid-configuration")
|
||||||
|
get, err := http.Get(confUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer get.Body.Close()
|
||||||
|
|
||||||
|
var c WellKnownOIDC
|
||||||
|
err = json.NewDecoder(get.Body).Decode(&c)
|
||||||
|
return &c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type WellKnownOIDC struct {
|
||||||
|
Config SsoConfig `json:"-"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||||
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||||
|
ScopesSupported []string `json:"scopes_supported"`
|
||||||
|
ClaimsSupported []string `json:"claims_supported"`
|
||||||
|
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o WellKnownOIDC) Validate() error {
|
||||||
|
if o.Issuer == "" {
|
||||||
|
return errors.New("missing issuer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// check URLs are valid
|
||||||
|
if _, err := url.Parse(o.AuthorizationEndpoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(o.TokenEndpoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(o.UserInfoEndpoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check oidc supported values
|
||||||
|
if !slices.Contains(o.ResponseTypesSupported, "code") {
|
||||||
|
return errors.New("missing required response type 'code'")
|
||||||
|
}
|
||||||
|
if !slices.Contains(o.ScopesSupported, "openid") {
|
||||||
|
return errors.New("missing required scope 'openid'")
|
||||||
|
}
|
||||||
|
requiredClaims := []string{"sub", "name", "preferred_username", "email", "email_verified"}
|
||||||
|
for _, i := range requiredClaims {
|
||||||
|
if !slices.Contains(o.ClaimsSupported, i) {
|
||||||
|
return fmt.Errorf("missing required claim '%s'", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidc valid
|
||||||
|
return nil
|
||||||
|
}
|
21
server/flow-popup.go.html
Normal file
21
server/flow-popup.go.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{.ServiceName}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{{.ServiceName}}</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<input type="hidden" name="return" value="{{.Return}}"/>
|
||||||
|
<div>
|
||||||
|
<label for="field_username">User Name:</label>
|
||||||
|
<input type="text" name="username" id="field_username" required/>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
49
server/flow.go
Normal file
49
server/flow.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed flow-popup.go.html
|
||||||
|
flowPopupHtml string
|
||||||
|
flowPopupTemplate *template.Template
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
pageParse, err := template.New("pages").Parse(flowPopupHtml)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("flow.go: Failed to parse flow popup HTML:", err)
|
||||||
|
}
|
||||||
|
flowPopupTemplate = pageParse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||||
|
err := flowPopupTemplate.Execute(rw, map[string]any{
|
||||||
|
"ServiceName": flowPopupTemplate,
|
||||||
|
"Return": req.URL.Query().Get("return"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to render page: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
login := h.manager.FindServiceFromLogin(req.PostFormValue("username"))
|
||||||
|
if login == nil {
|
||||||
|
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
login.AuthorizationEndpoint
|
||||||
|
|
||||||
|
// https://github.com/go-oauth2/oauth2/blob/master/example/client/client.go
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
|
||||||
|
}
|
47
server/server.go
Normal file
47
server/server.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/1f349/lavender/issuer"
|
||||||
|
"github.com/MrMelon54/mjwt"
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HttpServer struct {
|
||||||
|
r *httprouter.Router
|
||||||
|
baseUrl string
|
||||||
|
manager *issuer.Manager
|
||||||
|
signer mjwt.Signer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHttpServer(listen, baseUrl string, manager *issuer.Manager, signer mjwt.Signer) *http.Server {
|
||||||
|
r := httprouter.New()
|
||||||
|
|
||||||
|
hs := &HttpServer{
|
||||||
|
r: r,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
manager: manager,
|
||||||
|
signer: signer,
|
||||||
|
}
|
||||||
|
|
||||||
|
r.GET("/", func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = fmt.Fprintln(rw, "What is this?")
|
||||||
|
})
|
||||||
|
r.POST("/verify", hs.verifyHandler)
|
||||||
|
r.GET("/popup", hs.flowPopup)
|
||||||
|
r.POST("/popup", hs.flowPopupPost)
|
||||||
|
r.GET("/callback", hs.flowCallback)
|
||||||
|
|
||||||
|
return &http.Server{
|
||||||
|
Addr: listen,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: time.Minute,
|
||||||
|
ReadHeaderTimeout: time.Minute,
|
||||||
|
WriteTimeout: time.Minute,
|
||||||
|
IdleTimeout: time.Minute,
|
||||||
|
MaxHeaderBytes: 2500,
|
||||||
|
}
|
||||||
|
}
|
33
server/verify.go
Normal file
33
server/verify.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/1f349/violet/utils"
|
||||||
|
"github.com/MrMelon54/mjwt"
|
||||||
|
"github.com/MrMelon54/mjwt/auth"
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HttpServer) verifyHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
|
||||||
|
// find bearer token
|
||||||
|
bearer := utils.GetBearer(req)
|
||||||
|
if bearer == "" {
|
||||||
|
http.Error(rw, "Missing bearer", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// after this mjwt is considered valid
|
||||||
|
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signer, bearer)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "Invalid token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// check issuer against config
|
||||||
|
if b.Issuer != h.baseUrl {
|
||||||
|
http.Error(rw, "Invalid issuer", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user