indiescrobble/controllers/indieauth.go

348 lines
7.4 KiB
Go

package controllers
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
"git.jamesravey.me/ravenscroftj/indiescrobble/config"
"git.jamesravey.me/ravenscroftj/indiescrobble/models"
"github.com/gin-gonic/gin"
"github.com/go-chi/jwtauth/v5"
"github.com/hacdias/indieauth"
"github.com/lestrrat-go/jwx/jwt"
"gorm.io/gorm"
)
type IndieAuthManager struct {
iac *indieauth.Client
jwtAuth *jwtauth.JWTAuth
db *gorm.DB
}
func NewIndieAuthManager(db *gorm.DB) *IndieAuthManager {
config := config.GetConfig()
iam := new(IndieAuthManager)
iam.iac = indieauth.NewClient(config.GetString("indieauth.clientName"), config.GetString("indieauth.redirectURL"), nil)
iam.db = db
iam.jwtAuth = jwtauth.New("HS256", []byte(config.GetString("jwt.signKey")), []byte(config.GetString("jwt.signKey")))
return iam
}
func (iam *IndieAuthManager) GetCurrentUser(c *gin.Context) *models.BaseUser {
jwt, err := c.Cookie("jwt")
if err != nil {
return nil
} else {
tok, err := iam.jwtAuth.Decode(jwt)
if err != nil {
log.Printf("Failed to decode jwt: %v", err)
return nil
}
me, present := tok.Get("user")
if !present {
return nil
}
indietok, present := tok.Get("token")
if !present {
return nil
}
// see if the user exists in the database or set up their profile
userRecord := models.User{}
result := iam.db.First(&userRecord, models.User{Me: me.(string)})
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Printf("Create new user profile for user %v\n", me)
// create user record for current user
userRecord = models.User{Me: me.(string)}
userRecord.GenerateRandomKey()
result := iam.db.Create(&userRecord)
if result.Error != nil {
log.Printf("Failed to create user record in db: %v", result.Error)
return nil
}
} else {
log.Printf("Failed to get user from db: %v\n", result.Error)
return nil
}
}
user := models.BaseUser{Me: me.(string), Token: indietok.(string), UserRecord: &userRecord}
return &user
}
}
func (iam *IndieAuthManager) getInformation(c *gin.Context) (*indieauth.AuthInfo, string, error) {
config := config.GetConfig()
cookie, err := c.Request.Cookie(config.GetString("indieauth.oauthCookieName"))
if err != nil {
return nil, "", err
}
token, err := jwtauth.VerifyToken(iam.jwtAuth, cookie.Value)
if err != nil {
return nil, "", err
}
err = jwt.Validate(token)
if err != nil {
return nil, "", err
}
if token.Subject() != config.GetString("indieauth.oauthSubject") {
return nil, "", errors.New("invalid subject for oauth token")
}
data, ok := token.Get("data")
if !ok || data == nil {
return nil, "", errors.New("cannot find 'data' property in token")
}
dataStr, ok := data.(string)
if !ok || dataStr == "" {
return nil, "", errors.New("cannot find 'data' property in token")
}
var i *indieauth.AuthInfo
err = json.Unmarshal([]byte(dataStr), &i)
if err != nil {
return nil, "", err
}
// Delete cookie
http.SetCookie(c.Writer, &http.Cookie{
Name: config.GetString("indieauth.oauthCookieName"),
MaxAge: -1,
Secure: c.Request.URL.Scheme == "https",
Path: "/",
HttpOnly: true,
})
redirect, ok := token.Get("redirect")
if !ok {
return i, "", nil
}
redirectStr, ok := redirect.(string)
if !ok || redirectStr == "" {
return i, "", nil
}
return i, redirectStr, nil
}
func (iam *IndieAuthManager) saveAuthInfo(w http.ResponseWriter, r *http.Request, i *indieauth.AuthInfo) error {
data, err := json.Marshal(i)
if err != nil {
return err
}
config := config.GetConfig()
expiration := time.Now().Add(time.Minute * 10)
_, signed, err := iam.jwtAuth.Encode(map[string]interface{}{
jwt.SubjectKey: config.GetString("indieauth.oauthSubject"),
jwt.IssuedAtKey: time.Now().Unix(),
jwt.ExpirationKey: expiration,
"data": string(data),
"redirect": r.URL.Query().Get("redirect"),
})
if err != nil {
return err
}
cookie := &http.Cookie{
Name: config.GetString("indieauth.oauthCookieName"),
Value: string(signed),
Expires: expiration,
Secure: r.URL.Scheme == "https",
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
return nil
}
func (iam *IndieAuthManager) Logout(c *gin.Context) {
// delete the cookie
cookie := &http.Cookie{
Name: "jwt",
MaxAge: -1,
Secure: c.Request.URL.Scheme == "https",
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(c.Writer, cookie)
// redirect back to index
c.Redirect(http.StatusSeeOther, "/")
}
func (iam *IndieAuthManager) IndieAuthLoginPost(c *gin.Context) {
err := c.Request.ParseForm()
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
r := c.Request
profile := r.FormValue("domain")
if profile == "" {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": "Empty domain",
})
return
}
profile = indieauth.CanonicalizeURL(profile)
if err := indieauth.IsValidProfileURL(profile); err != nil {
err = fmt.Errorf("invalid profile url: %w", err)
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
i, redirect, err := iam.iac.Authenticate(profile, "")
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
fmt.Printf("profile: %v\n", i)
err = iam.saveAuthInfo(c.Writer, c.Request, i)
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
// append me param so the user doesn't have to enter this twice
redirect = fmt.Sprintf("%v&me=%v", redirect, url.QueryEscape(i.Me))
c.Redirect(http.StatusSeeOther, redirect)
}
func (iam *IndieAuthManager) LoginCallbackGet(c *gin.Context) {
config := config.GetConfig()
i, redirect, err := iam.getInformation(c)
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
code, err := iam.iac.ValidateCallback(i, c.Request)
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
// profile, err := iam.iac.FetchProfile(i, code)
// if err != nil {
// c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
// "message": err,
// })
// return
// }
token, _, err := iam.iac.GetToken(i, code)
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
me := token.Extra("me").(string)
if err := indieauth.IsValidProfileURL(me); err != nil {
err = fmt.Errorf("invalid 'me': %w", err)
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
expiration := time.Now().Add(time.Hour * 24 * 7)
_, signed, err := iam.jwtAuth.Encode(map[string]interface{}{
jwt.SubjectKey: config.GetString("indieauth.sessionSubject"),
jwt.IssuedAtKey: time.Now().Unix(),
jwt.ExpirationKey: expiration,
"user": me,
"token": token.AccessToken,
})
if err != nil {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"message": err,
})
return
}
cookie := &http.Cookie{
Name: "jwt",
Value: string(signed),
Expires: expiration,
Secure: c.Request.URL.Scheme == "https",
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(c.Writer, cookie)
if redirect == "" {
redirect = "/"
}
c.Redirect(http.StatusSeeOther, redirect)
}