From 84499d0ed9d49181488b9fa315f9c341405e3f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sat, 21 Oct 2023 10:26:15 +0200 Subject: [PATCH] Initial commit --- Dockerfile | 12 +++ README.md | 3 + cmd/main.go | 48 ++++++++++ go.mod | 23 +++++ go.sum | 44 ++++++++++ mf-auth.toml | 5 ++ models.go | 29 ++++++ server/config.go | 25 ++++++ server/middleware.go | 30 +++++++ server/server.go | 204 +++++++++++++++++++++++++++++++++++++++++++ server/userclient.go | 55 ++++++++++++ store/memory.go | 46 ++++++++++ store/store.go | 18 ++++ token.go | 13 +++ version.go | 3 + 15 files changed, 558 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 mf-auth.toml create mode 100644 models.go create mode 100644 server/config.go create mode 100644 server/middleware.go create mode 100644 server/server.go create mode 100644 server/userclient.go create mode 100644 store/memory.go create mode 100644 store/store.go create mode 100644 token.go create mode 100644 version.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f01ed66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:alpine as build +WORKDIR /app +COPY go.sum /app/go.sum +COPY go.mod /app/go.mod +RUN go mod download +COPY . /app +RUN go build -o mf-auth cmd/main.go + +FROM golang:alpine +COPY --from=build /app/mf-auth /usr/bin/mf-auth +WORKDIR /app +CMD ["/usr/bin/mf-auth"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d11fab --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# auth + +Auth service for microfilm \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..181e42d --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "log/slog" + "os" + + "git.t-juice.club/microfilm/auth/server" + "github.com/urfave/cli/v2" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + + app := &cli.App{ + Name: "mf-auth", + Usage: "Run microfilm auth service", + Action: func(*cli.Context) error { + // Read config + f, err := os.Open("mf-auth.toml") + if err != nil { + logger.Error("Error opening config.", "error", err) + os.Exit(1) + } + cfg, err := server.ConfigFromReader(f) + if err != nil { + logger.Error("Error parsing config.", "error", err) + os.Exit(1) + } + srv, err := server.NewServer(cfg) + if err != nil { + logger.Error("Error setting up server.", "error", err) + os.Exit(2) + } + + // Start server + srv.Logger = logger + logger.Info("Starting server.", "addr", srv.Addr) + return srv.ListenAndServe() + }, + } + + if err := app.Run(os.Args); err != nil { + logger.Error("Error running service.", "error", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c8a0992 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module git.t-juice.club/microfilm/auth + +go 1.21.3 + +require ( + github.com/go-chi/chi/v5 v5.0.10 + github.com/golang-jwt/jwt/v5 v5.0.0 + github.com/google/uuid v1.3.1 + github.com/nats-io/nats.go v1.31.0 + github.com/pelletier/go-toml/v2 v2.1.0 + github.com/urfave/cli/v2 v2.25.7 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/nats-io/nkeys v0.4.5 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f09813b --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= +github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= +github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= +github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mf-auth.toml b/mf-auth.toml new file mode 100644 index 0000000..738abd2 --- /dev/null +++ b/mf-auth.toml @@ -0,0 +1,5 @@ +ListenAddr = ":8082" +NATSAddr = "nats:4222" +BaseSubject = "microfilm.auth.v1" + +UserServiceBaseURL = "http://mf-users:8080" \ No newline at end of file diff --git a/models.go b/models.go new file mode 100644 index 0000000..5224f09 --- /dev/null +++ b/models.go @@ -0,0 +1,29 @@ +package auth + +// Request/response +type InfoResponse struct { + Version string `json:"version"` +} + +type ErrorResponse struct { + Status int `json:"status"` + Message string `json:"message"` +} + +type PubkeyResponse struct { + PubKey []byte `json:"publicKey"` +} + +type TokenRequest struct { + Password string `json:"password"` +} +type TokenResponse struct { + Token string `json:"token"` +} + +// Messages +type MsgTokenIssued struct { + Message string `json:"message"` + ID string `json:"jti"` + Subject string `json:"sub"` +} diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..923e23c --- /dev/null +++ b/server/config.go @@ -0,0 +1,25 @@ +package server + +import ( + "io" + + "github.com/pelletier/go-toml/v2" +) + +type Config struct { + ListenAddr string `toml:"ListenAddr"` + NATSAddr string `toml:"NATSAddr"` + BaseSubject string `toml:"BaseSubject"` + + UserServiceBaseURL string `toml:"UserServiceBaseURL"` +} + +func ConfigFromReader(r io.Reader) (*Config, error) { + decoder := toml.NewDecoder(r) + var c Config + if err := decoder.Decode(&c); err != nil { + return nil, err + } + + return &c, nil +} diff --git a/server/middleware.go b/server/middleware.go new file mode 100644 index 0000000..bc6e415 --- /dev/null +++ b/server/middleware.go @@ -0,0 +1,30 @@ +package server + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +func (s *Server) MiddlewareLogging(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + reqID := middleware.GetReqID(r.Context()) + + t1 := time.Now() + defer func(ww middleware.WrapResponseWriter) { + s.Logger.Info("Served request.", + "status", ww.Status(), + "path", r.URL.Path, + "duration", time.Since(t1), + "remote", r.RemoteAddr, + "written", ww.BytesWritten(), + "req", reqID, + ) + }(ww) + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..831cb45 --- /dev/null +++ b/server/server.go @@ -0,0 +1,204 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "git.t-juice.club/microfilm/auth" + "git.t-juice.club/microfilm/auth/store" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/nats-io/nats.go" +) + +const DefaultTokenDuration time.Duration = 24 * time.Hour + +type Server struct { + Logger *slog.Logger + + http.Server + store store.AuthStore + config *Config + nats *nats.EncodedConn + userClient *UserClient + + signingKey *ecdsa.PrivateKey +} + +func NewServer(config *Config) (*Server, error) { + srv := &Server{} + + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(srv.MiddlewareLogging) + + r.Get("/key", srv.PubkeyHandler) + r.Post("/{id}/token", srv.TokenHandler) + + srv.Handler = r + srv.Addr = config.ListenAddr + + srv.config = config + + srv.Logger = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + + srv.store = store.NewMemoryAuthStore() + + conn, err := nats.Connect(config.NATSAddr) + if err != nil { + return nil, err + } + encoded, err := nats.NewEncodedConn(conn, "json") + if err != nil { + return nil, err + } + + srv.nats = encoded + srv.userClient = NewUserClient(config.UserServiceBaseURL) + + // Generate keys + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + srv.signingKey = privateKey + + return srv, nil +} + +func InfoHandler(w http.ResponseWriter, r *http.Request) { + enc := json.NewEncoder(w) + + data := &auth.InfoResponse{ + Version: auth.Version, + } + + _ = enc.Encode(data) +} + +func WriteError(w http.ResponseWriter, response auth.ErrorResponse) { + encoder := json.NewEncoder(w) + w.WriteHeader(response.Status) + _ = encoder.Encode(&response) +} + +func (s *Server) PubkeyHandler(w http.ResponseWriter, r *http.Request) { + enc := json.NewEncoder(w) + key, err := x509.MarshalPKIXPublicKey(s.signingKey.Public()) + if err != nil { + s.Logger.Error("Unable to marshal public key.", "error", err) + WriteError(w, auth.ErrorResponse{ + Status: http.StatusInternalServerError, + Message: "Unable to marshal public key", + }) + return + } + response := auth.PubkeyResponse{ + PubKey: key, + } + + _ = enc.Encode(&response) +} + +func (s *Server) TokenHandler(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + defer r.Body.Close() + + userIdentifier := chi.URLParam(r, "id") + if userIdentifier == "" { + WriteError(w, auth.ErrorResponse{ + Status: http.StatusBadRequest, + Message: "Invalid user identifier.", + }) + return + } + + var request auth.TokenRequest + if err := decoder.Decode(&request); err != nil { + WriteError(w, auth.ErrorResponse{ + Status: http.StatusBadRequest, + Message: fmt.Sprintf("Error parsing request: %s", err), + }) + return + } + + if err := s.userClient.VerifyUserPassword(userIdentifier, request.Password); err != nil { + WriteError(w, auth.ErrorResponse{ + Status: http.StatusUnauthorized, + Message: fmt.Sprintf("Unable to verify password: %s", err), + }) + return + } + + exp := time.Now().Add(DefaultTokenDuration) + claims := auth.MicrofilmClaims{ + Role: auth.RoleUser, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "microfilm", + Subject: userIdentifier, + Audience: []string{"microfilm"}, + ExpiresAt: jwt.NewNumericDate(exp), + NotBefore: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(time.Now()), + ID: uuid.Must(uuid.NewRandom()).String(), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + tokenString, err := token.SignedString(s.signingKey) + if err != nil { + WriteError(w, auth.ErrorResponse{ + Status: http.StatusInternalServerError, + Message: fmt.Sprintf("Unable to sign token: %s", err), + }) + return + } + + if err := s.store.Add(store.RevokableToken{ + ID: claims.RegisteredClaims.ID, + Subject: claims.RegisteredClaims.Subject, + ExpiresAt: exp, + }); err != nil { + WriteError(w, auth.ErrorResponse{ + Status: http.StatusInternalServerError, + Message: fmt.Sprintf("Unable to store token for revocation: %s", err), + }) + return + } + + // Publish message + subject := fmt.Sprintf("%s.%s", s.config.BaseSubject, "issued") + msg := auth.MsgTokenIssued{ + Message: "Token issued.", + ID: claims.RegisteredClaims.ID, + Subject: claims.RegisteredClaims.Subject, + } + if err := s.nats.Publish(subject, msg); err != nil { + s.Logger.Error("Unable to publish message.", "error", err) + WriteError(w, auth.ErrorResponse{ + Status: http.StatusInternalServerError, + Message: fmt.Sprintf("Unable to store token for revocation: %s", err), + }) + return + } + + response := auth.TokenResponse{ + Token: tokenString, + } + + w.WriteHeader(http.StatusOK) + encoder := json.NewEncoder(w) + _ = encoder.Encode(&response) +} diff --git a/server/userclient.go b/server/userclient.go new file mode 100644 index 0000000..6b79a0b --- /dev/null +++ b/server/userclient.go @@ -0,0 +1,55 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type UserClient struct { + BaseURL string +} + +const defaultTimeout time.Duration = 5 * time.Second + +func NewUserClient(baseurl string) *UserClient { + return &UserClient{BaseURL: baseurl} +} + +func (c *UserClient) VerifyUserPassword(username, password string) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + url := fmt.Sprintf("%s/%s/verify", c.BaseURL, username) + body := struct { + Password string `json:"password"` + }{ + Password: password, + } + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + if err := enc.Encode(&body); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) + if err != nil { + return err + } + + client := http.Client{} + + resp, err := client.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("authentication failed") + } + + return nil +} diff --git a/store/memory.go b/store/memory.go new file mode 100644 index 0000000..25a7804 --- /dev/null +++ b/store/memory.go @@ -0,0 +1,46 @@ +package store + +type MemoryAuthStore struct { + Tokens map[string]RevokableToken +} + +func NewMemoryAuthStore() *MemoryAuthStore { + return &MemoryAuthStore{Tokens: map[string]RevokableToken{}} +} + +func (s *MemoryAuthStore) Revoke(id string) error { + if t, ok := s.Tokens[id]; ok { + t.Revoked = true + s.Tokens[id] = t + } + + return nil +} + +func (s *MemoryAuthStore) IsRevoked(id string) bool { + if t, ok := s.Tokens[id]; ok { + return t.Revoked + } + + return false +} + +func (s *MemoryAuthStore) Add(token RevokableToken) error { + s.Tokens[token.ID] = token + return nil +} + +func (s *MemoryAuthStore) Remove(id string) error { + delete(s.Tokens, id) + return nil +} + +func (s *MemoryAuthStore) RevokeUser(subject string) error { + for id, token := range s.Tokens { + if token.Subject == subject { + token.Revoked = true + s.Tokens[id] = token + } + } + return nil +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..d693c9e --- /dev/null +++ b/store/store.go @@ -0,0 +1,18 @@ +package store + +import "time" + +type RevokableToken struct { + ID string + Subject string + ExpiresAt time.Time + Revoked bool +} + +type AuthStore interface { + Add(token RevokableToken) error + Remove(id string) error + Revoke(id string) error + RevokeUser(subject string) error + IsRevoked(id string) bool +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..2e061f3 --- /dev/null +++ b/token.go @@ -0,0 +1,13 @@ +package auth + +import "github.com/golang-jwt/jwt/v5" + +const ( + RoleUser = "user" + RoleAdmin = "admin" +) + +type MicrofilmClaims struct { + Role string `json:"role"` + jwt.RegisteredClaims +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..b831765 --- /dev/null +++ b/version.go @@ -0,0 +1,3 @@ +package auth + +const Version = "v0.1.0"