Add authentication and metrics

This commit is contained in:
Kevin Kandlbinder 2023-03-07 15:14:08 +01:00
parent 7667ea7b90
commit 6abea91d7c
Signed by: kevin
GPG key ID: 1460B586646E180D
16 changed files with 894 additions and 31 deletions

87
internal/auth/auth.go Normal file
View file

@ -0,0 +1,87 @@
package auth
import (
"context"
"errors"
"github.com/Unkn0wnCat/calapi/internal/ghost"
"github.com/Unkn0wnCat/calapi/internal/logger"
"github.com/go-chi/chi/middleware"
"github.com/spf13/viper"
"go.uber.org/zap"
)
const (
AuthTypeGhost = "GHOST"
AuthTypeNone = "NONE"
)
type User struct {
ID string
Username string
Name string
}
func Authenticate(ctx context.Context, username string, password string) (*User, error) {
switch viper.GetString("auth.type") {
case AuthTypeGhost:
return AuthenticateGhost(ctx, username, password)
case AuthTypeNone:
logger.Logger.Info("anonymously authenticated",
zap.String("requestId", middleware.GetReqID(ctx)),
)
return &User{
ID: "ANON-NO-AUTH",
Username: "anonymous",
Name: "Anonymous User",
}, nil
default:
return nil, errors.New("unknown authentication method")
}
}
func AuthenticateGhost(ctx context.Context, username string, password string) (*User, error) {
api := ghost.GhostAPI{
BaseURL: viper.GetString("auth.ghost.base_url"),
Jar: nil,
}
_, err := api.Login(username, password)
if err != nil {
logger.Logger.Warn("invalid ghost credentials",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.Error(err),
)
return nil, err
}
ghostUser, err := api.UserSelf()
if err != nil {
logger.Logger.Error("ghost error",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.Error(err),
)
return nil, err
}
if len(ghostUser.Users) == 0 {
logger.Logger.Error("unexpected empty response from Ghost API",
zap.String("requestId", middleware.GetReqID(ctx)),
)
return nil, errors.New("unexpected empty response from Ghost API")
}
logger.Logger.Info("ghost authentication success",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.String("userId", ghostUser.Users[0].ID),
zap.String("username", ghostUser.Users[0].Email),
zap.Error(err),
)
user := &User{
ID: ghostUser.Users[0].ID,
Username: ghostUser.Users[0].Email,
Name: ghostUser.Users[0].Name,
}
return user, nil
}

View file

@ -0,0 +1,88 @@
package auth
import (
"context"
"errors"
"github.com/99designs/gqlgen/graphql"
"github.com/Unkn0wnCat/calapi/internal/logger"
"github.com/go-chi/chi/middleware"
"github.com/spf13/viper"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.uber.org/zap"
)
func ChallengeQuery(ctx context.Context) error {
if viper.GetBool("auth.anonymous_read") == true || viper.GetString("auth.type") == AuthTypeNone {
return nil // Anonymous querying is allowed. Anyone is allowed.
}
user := ForContext(ctx)
if user == nil {
logger.Logger.Warn("unauthorized query attempt",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.String("gqlPath", graphql.GetPath(ctx).String()),
)
graphql.AddError(ctx, &gqlerror.Error{
Message: "A login token is required, but was not provided.",
Path: graphql.GetPath(ctx),
})
return errors.New("no user found")
}
if user.ID == "ANON-NO-AUTH" {
// This login was done when auth was turned off.
logger.Logger.Warn("anonymous query attempt",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.String("gqlPath", graphql.GetPath(ctx).String()),
)
graphql.AddError(ctx, &gqlerror.Error{
Message: "The provided login token was anonymous, but this was since disabled. Please reauthenticate.",
Path: graphql.GetPath(ctx),
})
return errors.New("anonymous auth disabled")
}
return nil // User is set.
}
func ChallengeMutation(ctx context.Context) error {
if viper.GetString("auth.type") == AuthTypeNone {
return nil // Anonymous mutations are allowed. Anyone is allowed.
}
user := ForContext(ctx)
if user == nil {
logger.Logger.Warn("unauthorized mutation attempt",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.String("gqlPath", graphql.GetPath(ctx).String()),
)
graphql.AddError(ctx, &gqlerror.Error{
Message: "A login token is required, but was not provided.",
Path: graphql.GetPath(ctx),
})
return errors.New("no user found")
}
if user.ID == "ANON-NO-AUTH" {
// This login was done when auth was turned off.
logger.Logger.Warn("anonymous mutation attempt",
zap.String("requestId", middleware.GetReqID(ctx)),
zap.String("gqlPath", graphql.GetPath(ctx).String()),
)
graphql.AddError(ctx, &gqlerror.Error{
Message: "The provided login token was anonymous, but this was since disabled. Please reauthenticate.",
Path: graphql.GetPath(ctx),
})
return errors.New("anonymous auth disabled")
}
return nil // User is set.
}

60
internal/auth/context.go Normal file
View file

@ -0,0 +1,60 @@
package auth
import (
"context"
"github.com/Unkn0wnCat/calapi/internal/logger"
"github.com/go-chi/chi/middleware"
"go.uber.org/zap"
"net/http"
"strings"
)
var userCtxKey = &contextKey{"user"}
type contextKey struct {
name string
}
func Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
authScheme := auth[0]
if len(auth) != 2 || (!strings.EqualFold(authScheme, "bearer") && !strings.EqualFold(authScheme, "jwt")) {
// No authentication provided
next.ServeHTTP(w, r)
return
}
authPayload := auth[1]
user, err := ParseJWT(authPayload)
if err != nil {
logger.Logger.Warn("invalid token provided",
zap.String("requestId", middleware.GetReqID(r.Context())),
zap.Error(err),
)
http.Error(w, "Invalid Token", http.StatusUnauthorized)
return
}
logger.Logger.Info("token validated",
zap.String("requestId", middleware.GetReqID(r.Context())),
zap.String("userId", user.ID),
zap.String("username", user.Username),
)
// put it in context
ctx := context.WithValue(r.Context(), userCtxKey, user)
// and call the next with our new context
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}
func ForContext(ctx context.Context) *User {
raw, _ := ctx.Value(userCtxKey).(*User)
return raw
}

View file

@ -0,0 +1,9 @@
package auth
import "github.com/golang-jwt/jwt/v5"
type JwtClaims struct {
Username string `json:"username"`
Name string `json:"name"`
jwt.RegisteredClaims
}

View file

@ -0,0 +1,60 @@
package auth
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
"time"
)
func ParseJWT(tokenString string) (*User, error) {
claims := JwtClaims{}
jwtSigningKey := []byte(viper.GetString("auth.secret"))
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
return jwtSigningKey, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
user := &User{
ID: claims.Subject,
Username: claims.Username,
Name: claims.Name,
}
return user, nil
}
func MakeJWT(user *User) (string, error) {
if user == nil {
return "", errors.New("no user provided")
}
claims := JwtClaims{
Username: user.Username,
Name: user.Name,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "calapi",
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
jwtSigningKey := []byte(viper.GetString("auth.secret"))
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(jwtSigningKey)
if err != nil {
return "", err
}
return ss, nil
}

165
internal/ghost/api.go Normal file
View file

@ -0,0 +1,165 @@
package ghost
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
)
type GhostAPI struct {
BaseURL string
Jar *cookiejar.Jar
}
func (api *GhostAPI) get(path string, query string) (response *http.Response, err error) {
requestUrl, err := url.JoinPath(api.BaseURL, path)
if err != nil {
return nil, err
}
if api.Jar == nil {
api.Jar, err = cookiejar.New(nil)
if err != nil {
return nil, err
}
}
client := http.Client{Jar: api.Jar}
response, err = client.Get(requestUrl + query)
if err != nil {
return response, err
}
if response.StatusCode > 299 {
return response, fmt.Errorf("server returned status %d", response.StatusCode)
}
return response, nil
}
func (api *GhostAPI) post(path string, data interface{}) (response *http.Response, err error) {
requestUrl, err := url.JoinPath(api.BaseURL, path)
if err != nil {
return nil, err
}
if api.Jar == nil {
api.Jar, err = cookiejar.New(nil)
if err != nil {
return nil, err
}
}
client := http.Client{Jar: api.Jar}
marshaledData, err := json.Marshal(data)
if err != nil {
return nil, err
}
dataReader := bytes.NewReader(marshaledData)
response, err = client.Post(requestUrl, "application/json", dataReader)
if err != nil {
return response, err
}
if response.StatusCode > 299 {
return response, fmt.Errorf("server returned status %d", response.StatusCode)
}
return response, nil
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (api *GhostAPI) Login(username string, password string) (token string, err error) {
data := LoginRequest{
Username: username,
Password: password,
}
response, err := api.post("/api/admin/session/", data)
if err != nil {
return "", err
}
cookies := response.Cookies()
for _, cookie := range cookies {
if cookie.Name != "ghost-admin-api-session" {
continue
}
token = cookie.Value
break
}
return token, nil
}
type UserData struct {
Users []User `json:"users"`
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Email string `json:"email"`
ProfileImage string `json:"profile_image"`
CoverImage string `json:"cover_image"`
Bio string `json:"bio"`
Website string `json:"website"`
Location string `json:"location"`
Facebook string `json:"facebook"`
Twitter string `json:"twitter"`
Accessibility string `json:"accessibility"`
Status string `json:"status"`
MetaTitle string `json:"meta_title"`
MetaDescription string `json:"meta_description"`
Tour string `json:"tour"`
LastSeen time.Time `json:"last_seen"`
CommentNotifications bool `json:"comment_notifications"`
FreeMemberSignupNotification bool `json:"free_member_signup_notification"`
PaidSubscriptionStartedNotification bool `json:"paid_subscription_started_notification"`
PaidSubscriptionCanceledNotification bool `json:"paid_subscription_canceled_notification"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Roles []Role `json:"roles"`
Url string `json:"url"`
}
type Role struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (api *GhostAPI) UserSelf() (user *UserData, err error) {
response, err := api.get("/api/admin/users/me", "?include=roles")
if err != nil {
return nil, err
}
user = &UserData{}
decoder := json.NewDecoder(response.Body)
err = decoder.Decode(user)
if err != nil {
return nil, err
}
return user, nil
}

21
internal/logger/logger.go Normal file
View file

@ -0,0 +1,21 @@
package logger
import (
"github.com/spf13/viper"
"go.uber.org/zap"
)
var (
Logger *zap.Logger
Sugar *zap.SugaredLogger
)
func StartLogger() {
Logger, _ = zap.NewProduction()
if viper.GetBool("development") {
Logger, _ = zap.NewDevelopment()
}
Sugar = Logger.Sugar()
}

View file

@ -1,28 +1,69 @@
package server
import (
chiprometheus "github.com/766b/chi-prometheus"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/Unkn0wnCat/calapi/graph"
"log"
"github.com/Unkn0wnCat/calapi/internal/auth"
"github.com/Unkn0wnCat/calapi/internal/logger"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
"net/http"
"os"
"time"
)
const defaultPort = "8080"
func logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wrappedResponse := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
latencyStart := time.Now()
defer func() {
latency := time.Since(latencyStart)
logger.Logger.Info("HTTP request finished",
zap.String("proto", r.Proto),
zap.String("uri", r.RequestURI),
zap.String("path", r.URL.Path),
zap.String("method", r.Method),
zap.String("remote", r.RemoteAddr),
zap.Int("status", wrappedResponse.Status()),
zap.Int("size", wrappedResponse.BytesWritten()),
zap.Duration("latency", latency),
zap.String("requestId", middleware.GetReqID(r.Context())),
)
}()
next.ServeHTTP(wrappedResponse, r)
})
}
func Serve() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
router := chi.NewRouter()
m := chiprometheus.NewMiddleware("calapi")
router.Use(m)
router.Use(middleware.RequestID)
router.Use(logMiddleware)
router.Use(middleware.Recoverer)
router.Use(auth.Middleware())
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{}}))
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)
router.Handle("/", playground.Handler("GraphQL playground", "/query"))
router.Handle("/query", srv)
router.Handle("/metrics", promhttp.Handler())
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
logger.Sugar.Infof("Now serving at http://localhost:%s/", port)
logger.Sugar.Fatal(http.ListenAndServe(":"+port, router))
}