Initial commit

This commit is contained in:
Torjus Håkestad 2023-10-21 10:26:15 +02:00
commit 84499d0ed9
15 changed files with 558 additions and 0 deletions

12
Dockerfile Normal file
View File

@ -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"]

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# auth
Auth service for microfilm

48
cmd/main.go Normal file
View File

@ -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)
}
}

23
go.mod Normal file
View File

@ -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
)

44
go.sum Normal file
View File

@ -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=

5
mf-auth.toml Normal file
View File

@ -0,0 +1,5 @@
ListenAddr = ":8082"
NATSAddr = "nats:4222"
BaseSubject = "microfilm.auth.v1"
UserServiceBaseURL = "http://mf-users:8080"

29
models.go Normal file
View File

@ -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"`
}

25
server/config.go Normal file
View File

@ -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
}

30
server/middleware.go Normal file
View File

@ -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)
}

204
server/server.go Normal file
View File

@ -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)
}

55
server/userclient.go Normal file
View File

@ -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
}

46
store/memory.go Normal file
View File

@ -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
}

18
store/store.go Normal file
View File

@ -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
}

13
token.go Normal file
View File

@ -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
}

3
version.go Normal file
View File

@ -0,0 +1,3 @@
package auth
const Version = "v0.1.0"