got auth flow working with indieauth

This commit is contained in:
James Ravenscroft 2022-02-05 16:24:07 +00:00
parent 2d551b4e37
commit 53ef48df37
10 changed files with 351 additions and 33 deletions

View File

@ -14,12 +14,25 @@ var config *viper.Viper
func Init(env string) {
var err error
config = viper.New()
config.SetDefault("indieauth.clientName", "https://indiescrobble.club")
config.SetDefault("indieauth.redirectURL", "http://localhost:3000/auth")
config.SetDefault("indieauth.oauthSubject", "IndieScrobble OAuth Client")
config.SetDefault("indieauth.oauthCookieName","indiescrobble-oauth")
config.SetDefault("indieauth.sessionSubject", "IndieScrobble Session")
config.SetConfigType("yaml")
config.SetConfigName(env)
config.AddConfigPath("../config/")
config.AddConfigPath("config/")
err = config.ReadInConfig()
if config.GetString("jwt.signKey") == ""{
log.Fatal("You must set a JWT sign key (jwt.signKey in config yaml)")
}
config.BindEnv("server.port","PORT")
if err != nil {

View File

@ -2,3 +2,7 @@ server:
port: ":8081"
static_path: ./static
jwt:
signKey: "profiteroles"
verifyKey: "topSecret"

View File

@ -1,20 +1,286 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
"git.jamesravey.me/ravenscroftj/indiescrobble/config"
"github.com/gin-gonic/gin"
"github.com/go-chi/jwtauth/v5"
"github.com/hacdias/indieauth"
"github.com/lestrrat-go/jwx/jwt"
)
type IndieAuthManager struct {
iac *indieauth.Client
jwtAuth *jwtauth.JWTAuth
}
func IndieAuthLoginPost(c *gin.Context) {
func NewIndieAuthManager() *IndieAuthManager{
config := config.GetConfig()
iam := new(IndieAuthManager)
iam.iac = indieauth.NewClient(config.GetString("indieauth.clientName"), config.GetString("indieauth.redirectURL"), nil)
iam.jwtAuth = jwtauth.New("HS256", []byte(config.GetString("jwt.signKey")), []byte(config.GetString("jwt.signKey")))
return iam
}
func (iam *IndieAuthManager) GetCurrentUser(c *gin.Context) string {
jwt, err := c.Cookie("jwt")
if err != nil {
return ""
}else{
tok, err := iam.jwtAuth.Decode(jwt)
if err != nil{
log.Printf("Failed to decode jwt: %v", err)
return ""
}
val, present := tok.Get("user")
if present {
return fmt.Sprintf("%v", val)
}else{
return ""
}
}
}
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) IndieAuthLoginPost(c *gin.Context) {
err := c.Request.ParseForm()
if err != nil{
c.HTML(http.StatusBadRequest, "error.html", gin.H{
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
}
if err := indieauth.IsValidProfileURL(profile.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": profile.Me,
})
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)
}

1
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.7.7 // indirect
github.com/go-chi/jwtauth/v5 v5.0.2 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.0 // indirect

8
go.sum
View File

@ -73,6 +73,9 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/jwtauth/v5 v5.0.2 h1:CSKtr+b6Jnfy5T27sMaiBPxaVE/bjnjS3ramFQ0526w=
github.com/go-chi/jwtauth/v5 v5.0.2/go.mod h1:TeA7vmPe3uYThvHw8O8W13HOOpOd4MTgToxL41gZyjs=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -86,6 +89,7 @@ github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.9.4 h1:L8MLKG2mvVXiQu07qB6hmfqeSYQdOnqPot2GhsIwIaI=
github.com/goccy/go-json v0.9.4/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@ -176,10 +180,12 @@ github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4=
github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=
github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
github.com/lestrrat-go/jwx v1.2.18 h1:RV4hcTRUlPVYUnGqATKXEojoOsLexoU8Na4KheVzxQ8=
github.com/lestrrat-go/jwx v1.2.18/go.mod h1:bWTBO7IHHVMtNunM8so9MT8wD+euEY1PzGEyCnuI2qM=
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
@ -448,11 +454,13 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -1,30 +1,38 @@
package middlewares
import (
"strings"
"fmt"
"git.jamesravey.me/ravenscroftj/indiescrobble/config"
"git.jamesravey.me/ravenscroftj/indiescrobble/controllers"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
config := config.GetConfig()
reqKey := c.Request.Header.Get("X-Auth-Key")
reqSecret := c.Request.Header.Get("X-Auth-Secret")
// config := config.GetConfig()
var key string
var secret string
if key = config.GetString("http.auth.key"); len(strings.TrimSpace(key)) == 0 {
c.AbortWithStatus(500)
}
if secret = config.GetString("http.auth.secret"); len(strings.TrimSpace(secret)) == 0 {
c.AbortWithStatus(401)
}
if key != reqKey || secret != reqSecret {
c.AbortWithStatus(401)
return
}
iam := controllers.NewIndieAuthManager()
fmt.Printf("Current user: %v\n", iam.GetCurrentUser(c))
// reqKey := c.Request.Header.Get("X-Auth-Key")
// reqSecret := c.Request.Header.Get("X-Auth-Secret")
// var key string
// var secret string
// if key = config.GetString("http.auth.key"); len(strings.TrimSpace(key)) == 0 {
// c.AbortWithStatus(500)
// }
// if secret = config.GetString("http.auth.secret"); len(strings.TrimSpace(secret)) == 0 {
// c.AbortWithStatus(401)
// }
// if key != reqKey || secret != reqSecret {
// c.AbortWithStatus(401)
// return
// }
c.Next()
}
}

View File

@ -16,14 +16,22 @@ func NewRouter() *gin.Engine {
health := new(controllers.HealthController)
iam := controllers.NewIndieAuthManager()
router.GET("/health", health.Status)
router.Use(middlewares.AuthMiddleware())
router.GET("/", controllers.Index)
router.Static("/static", config.GetString("server.static_path"))
router.POST("/indieauth", iam.IndieAuthLoginPost)
router.GET("/auth", iam.LoginCallbackGet)
router.Use(middlewares.AuthMiddleware())
// v1 := router.Group("v1")
// {

View File

@ -1,16 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/static/css/simple-v1.css" />
</head>
{{ template "head.tmpl" . }}
<body>
<header>
<h1>
IndieScrobble
</h1>
</header>
{{ template "header.tmpl" . }}
<main>
<p>{{ .message }}</p>
<h2>Error</h2>
{{ .message }}
</main>
</body>
</html>

6
templates/head.tmpl Normal file
View File

@ -0,0 +1,6 @@
{{ define "head.tmpl" }}
<head>
<link rel="stylesheet" href="/static/css/simple-v1.css" />
<title>IndieScrobble</title>
</head>
{{end}}

View File

@ -1,13 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/static/css/simple-v1.css" />
</head>
{{ template "head.tmpl" . }}
<body>
{{ template "header.tmpl" . }}
<main>
<p>Welcome to indiescrobble! IndieScrobble is a <a href="https://micropub.spec.indieweb.org/">MicroPub</a> compliant tool
for posting about your watches, reads and scrobbles directly back to your site.</p>
{{ if index . "user" }}
{{else}}
<form action="/indieauth" method="POST">
<p>
<label>Your domain: </label>
<input type="text" name="domain"/>
<button type="submit">Log in</button>
</p>
</form>
{{ end }}
</main>
</body>
</html>