mirror of
https://github.com/Unkn0wnCat/calapi.git
synced 2025-08-01 10:38:17 +02:00
Add authentication and metrics
This commit is contained in:
parent
7667ea7b90
commit
6abea91d7c
16 changed files with 894 additions and 31 deletions
87
internal/auth/auth.go
Normal file
87
internal/auth/auth.go
Normal 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
|
||||
}
|
88
internal/auth/challenges.go
Normal file
88
internal/auth/challenges.go
Normal 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
60
internal/auth/context.go
Normal 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
|
||||
}
|
9
internal/auth/jwtClaims.go
Normal file
9
internal/auth/jwtClaims.go
Normal 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
|
||||
}
|
60
internal/auth/jwtHelpers.go
Normal file
60
internal/auth/jwtHelpers.go
Normal 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
165
internal/ghost/api.go
Normal 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
21
internal/logger/logger.go
Normal 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()
|
||||
}
|
|
@ -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))
|
||||
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue