diff --git a/config/config.go b/config/config.go index 29b579f..6b593f5 100644 --- a/config/config.go +++ b/config/config.go @@ -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 { diff --git a/config/development.yaml b/config/development.yaml index c3a22fb..fb662be 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -1,4 +1,8 @@ server: port: ":8081" - static_path: ./static \ No newline at end of file + static_path: ./static + +jwt: + signKey: "profiteroles" + verifyKey: "topSecret" \ No newline at end of file diff --git a/controllers/indieauth.go b/controllers/indieauth.go index 81edbc8..58c66ce 100644 --- a/controllers/indieauth.go +++ b/controllers/indieauth.go @@ -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) +} \ No newline at end of file diff --git a/go.mod b/go.mod index a51553e..27ff10b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 69c642a..c10d714 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/middlewares/auth.go b/middlewares/auth.go index 6b74c57..abe549c 100644 --- a/middlewares/auth.go +++ b/middlewares/auth.go @@ -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() } } + diff --git a/server/router.go b/server/router.go index f2318f7..3a6af7c 100644 --- a/server/router.go +++ b/server/router.go @@ -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") // { diff --git a/templates/error.tmpl b/templates/error.tmpl index 774aa8e..c7e2bd2 100644 --- a/templates/error.tmpl +++ b/templates/error.tmpl @@ -1,16 +1,11 @@ - - - + {{ template "head.tmpl" . }} -
-

- IndieScrobble -

-
+ {{ template "header.tmpl" . }}
-

{{ .message }}

+

Error

+ {{ .message }}
\ No newline at end of file diff --git a/templates/head.tmpl b/templates/head.tmpl new file mode 100644 index 0000000..090676e --- /dev/null +++ b/templates/head.tmpl @@ -0,0 +1,6 @@ +{{ define "head.tmpl" }} + + + IndieScrobble + +{{end}} \ No newline at end of file diff --git a/templates/index.tmpl b/templates/index.tmpl index 7753e85..12cfacb 100644 --- a/templates/index.tmpl +++ b/templates/index.tmpl @@ -1,13 +1,22 @@ - - - + {{ template "head.tmpl" . }} {{ template "header.tmpl" . }}

Welcome to indiescrobble! IndieScrobble is a MicroPub compliant tool for posting about your watches, reads and scrobbles directly back to your site.

+ + {{ if index . "user" }} + {{else}} +
+

+ + + +

+
+ {{ end }}
\ No newline at end of file