mirror of https://github.com/jlelse/GoBlog
IndieAuth server
This commit is contained in:
parent
6624ac9ecd
commit
e611a5008d
|
@ -120,9 +120,6 @@ type configMicropub struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
AuthAllowed []string `mapstructure:"authAllowed"`
|
AuthAllowed []string `mapstructure:"authAllowed"`
|
||||||
TokenEndpoint string `mapstructure:"tokenEndpoint"`
|
|
||||||
AuthEndpoint string `mapstructure:"authEndpoint"`
|
|
||||||
Authn string `mapstructure:"authn"`
|
|
||||||
CategoryParam string `mapstructure:"categoryParam"`
|
CategoryParam string `mapstructure:"categoryParam"`
|
||||||
ReplyParam string `mapstructure:"replyParam"`
|
ReplyParam string `mapstructure:"replyParam"`
|
||||||
LikeParam string `mapstructure:"likeParam"`
|
LikeParam string `mapstructure:"likeParam"`
|
||||||
|
@ -157,9 +154,6 @@ func initConfig() error {
|
||||||
viper.SetDefault("hugo.frontmatter", []*frontmatter{{Meta: "title", Parameter: "title"}, {Meta: "tags", Parameter: "tags"}})
|
viper.SetDefault("hugo.frontmatter", []*frontmatter{{Meta: "title", Parameter: "title"}, {Meta: "tags", Parameter: "tags"}})
|
||||||
viper.SetDefault("micropub.enabled", true)
|
viper.SetDefault("micropub.enabled", true)
|
||||||
viper.SetDefault("micropub.path", "/micropub")
|
viper.SetDefault("micropub.path", "/micropub")
|
||||||
viper.SetDefault("micropub.authAllowed", []string{})
|
|
||||||
viper.SetDefault("micropub.tokenEndpoint", "https://tokens.indieauth.com/token")
|
|
||||||
viper.SetDefault("micropub.authEndpoint", "https://indieauth.com/auth")
|
|
||||||
viper.SetDefault("micropub.categoryParam", "tags")
|
viper.SetDefault("micropub.categoryParam", "tags")
|
||||||
viper.SetDefault("micropub.replyParam", "replylink")
|
viper.SetDefault("micropub.replyParam", "replylink")
|
||||||
viper.SetDefault("micropub.likeParam", "likelink")
|
viper.SetDefault("micropub.likeParam", "likelink")
|
||||||
|
@ -179,5 +173,8 @@ func initConfig() error {
|
||||||
if len(appConfig.DefaultBlog) == 0 || appConfig.Blogs[appConfig.DefaultBlog] == nil {
|
if len(appConfig.DefaultBlog) == 0 || appConfig.Blogs[appConfig.DefaultBlog] == nil {
|
||||||
return errors.New("no default blog or default blog not present")
|
return errors.New("no default blog or default blog not present")
|
||||||
}
|
}
|
||||||
|
if len(appConfig.Micropub.AuthAllowed) == 0 {
|
||||||
|
appConfig.Micropub.AuthAllowed = []string{appConfig.Server.Domain}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,9 @@ func migrateDb() error {
|
||||||
CREATE TABLE post_parameters (id integer primary key autoincrement, path text not null, parameter text not null, value text);
|
CREATE TABLE post_parameters (id integer primary key autoincrement, path text not null, parameter text not null, value text);
|
||||||
CREATE INDEX index_pp_path on post_parameters (path);
|
CREATE INDEX index_pp_path on post_parameters (path);
|
||||||
CREATE TABLE redirects (fromPath text not null, toPath text not null, primary key (fromPath, toPath));
|
CREATE TABLE redirects (fromPath text not null, toPath text not null, primary key (fromPath, toPath));
|
||||||
|
CREATE TABLE indieauthauth (time text not null, code text not null, me text not null, client text not null, redirect text not null, scope text not null);
|
||||||
|
CREATE TABLE indieauthtoken (time text not null, token text not null, me text not null, client text not null, scope text not null);
|
||||||
|
CREATE INDEX index_iat_token on indieauthtoken (token);
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
|
|
@ -58,9 +58,6 @@ micropub:
|
||||||
path: /micropub
|
path: /micropub
|
||||||
authAllowed:
|
authAllowed:
|
||||||
- example.com
|
- example.com
|
||||||
tokenEndpoint: https://tokens.indieauth.com/token
|
|
||||||
authEndpoint: https://indieauth.com/auth
|
|
||||||
authn: login@example.com
|
|
||||||
categoryParam: tags
|
categoryParam: tags
|
||||||
replyParam: replylink
|
replyParam: replylink
|
||||||
likeParam: likelink
|
likeParam: likelink
|
||||||
|
|
6
go.mod
6
go.mod
|
@ -39,12 +39,12 @@ require (
|
||||||
github.com/yuin/goldmark-emoji v1.0.1
|
github.com/yuin/goldmark-emoji v1.0.1
|
||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
go.uber.org/zap v1.16.0 // indirect
|
go.uber.org/zap v1.16.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee // indirect
|
||||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect
|
||||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb // indirect
|
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb // indirect
|
||||||
golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520 // indirect
|
golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520 // indirect
|
||||||
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 // indirect
|
golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb // indirect
|
||||||
golang.org/x/tools v0.0.0-20201011145850-ed2f50202694 // indirect
|
golang.org/x/tools v0.0.0-20201013183236-0112737ef124 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
|
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
|
||||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.3.0 // indirect
|
gopkg.in/yaml.v2 v2.3.0 // indirect
|
||||||
|
|
12
go.sum
12
go.sum
|
@ -331,8 +331,8 @@ golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPh
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI=
|
||||||
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
@ -414,8 +414,8 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c h1:UIcGWL6/wpCfyGuJnRFJRurA+yj8RrW7Q6x2YMCXt6c=
|
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c h1:UIcGWL6/wpCfyGuJnRFJRurA+yj8RrW7Q6x2YMCXt6c=
|
||||||
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
|
golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb h1:HS9IzC4UFbpMBLQUDSQcU+ViVT1vdFCQVjdPVpTlZrs=
|
||||||
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
|
@ -447,8 +447,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
||||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20201011145850-ed2f50202694 h1:BANdcOVw3KTuUiyfDp7wrzCpkCe8UP3lowugJngxBTg=
|
golang.org/x/tools v0.0.0-20201013183236-0112737ef124 h1:CQRvWGvGfDDk3OMpDX19jvU16tKf5RlqlJc+ki94ZJs=
|
||||||
golang.org/x/tools v0.0.0-20201011145850-ed2f50202694/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
golang.org/x/tools v0.0.0-20201013183236-0112737ef124/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
17
http.go
17
http.go
|
@ -70,11 +70,13 @@ func buildHandler() (http.Handler, error) {
|
||||||
r.Mount("/debug", middleware.Profiler())
|
r.Mount("/debug", middleware.Profiler())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authMiddleware := middleware.BasicAuth("API", map[string]string{
|
||||||
|
appConfig.User.Nick: appConfig.User.Password,
|
||||||
|
})
|
||||||
|
|
||||||
// API
|
// API
|
||||||
r.Route("/api", func(apiRouter chi.Router) {
|
r.Route("/api", func(apiRouter chi.Router) {
|
||||||
apiRouter.Use(middleware.BasicAuth("API", map[string]string{
|
apiRouter.Use(authMiddleware)
|
||||||
appConfig.User.Nick: appConfig.User.Password,
|
|
||||||
}))
|
|
||||||
apiRouter.Post("/post", apiPostCreate)
|
apiRouter.Post("/post", apiPostCreate)
|
||||||
apiRouter.Get("/post", apiPostRead)
|
apiRouter.Get("/post", apiPostRead)
|
||||||
apiRouter.Delete("/post", apiPostDelete)
|
apiRouter.Delete("/post", apiPostDelete)
|
||||||
|
@ -88,6 +90,15 @@ func buildHandler() (http.Handler, error) {
|
||||||
r.With(checkIndieAuth).Post(appConfig.Micropub.Path+micropubMediaSubPath, serveMicropubMedia)
|
r.With(checkIndieAuth).Post(appConfig.Micropub.Path+micropubMediaSubPath, serveMicropubMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IndieAuth
|
||||||
|
r.Route("/indieauth", func(indieauthRouter chi.Router) {
|
||||||
|
indieauthRouter.With(authMiddleware).Get("/", indieAuthAuth)
|
||||||
|
indieauthRouter.With(authMiddleware).Post("/accept", indieAuthAccept)
|
||||||
|
indieauthRouter.Post("/", indieAuthAuth)
|
||||||
|
indieauthRouter.Get("/token", indieAuthToken)
|
||||||
|
indieauthRouter.Post("/token", indieAuthToken)
|
||||||
|
})
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
allPostPaths, err := allPostPaths()
|
allPostPaths, err := allPostPaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
51
indieauth.go
51
indieauth.go
|
@ -1,44 +1,26 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type indieAuthTokenResponse struct {
|
|
||||||
Me string `json:"me"`
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
IssuedBy string `json:"issued_by"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
ErrorDescription string `json:"error_description"`
|
|
||||||
StatusCode int
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkIndieAuth(next http.Handler) http.Handler {
|
func checkIndieAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
bearerToken := r.Header.Get("Authorization")
|
bearerToken := r.Header.Get("Authorization")
|
||||||
if len(bearerToken) == 0 {
|
if len(bearerToken) == 0 {
|
||||||
if accessToken := r.URL.Query().Get("access_token"); len(accessToken) > 0 {
|
bearerToken = r.URL.Query().Get("access_token")
|
||||||
bearerToken = "Bearer " + accessToken
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tokenResponse, err := verifyIndieAuthAccessToken(bearerToken)
|
tokenData, err := verifyIndieAuthToken(bearerToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tokenResponse.StatusCode != http.StatusOK {
|
|
||||||
http.Error(w, "Failed to retrieve authentication information", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authorized := false
|
authorized := false
|
||||||
for _, allowed := range appConfig.Micropub.AuthAllowed {
|
for _, allowed := range appConfig.Micropub.AuthAllowed {
|
||||||
if err := compareHostnames(tokenResponse.Me, allowed); err == nil {
|
if err := compareHostnames(tokenData.Me, allowed); err == nil {
|
||||||
authorized = true
|
authorized = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -52,33 +34,6 @@ func checkIndieAuth(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyIndieAuthAccessToken(bearerToken string) (*indieAuthTokenResponse, error) {
|
|
||||||
if len(bearerToken) == 0 {
|
|
||||||
return nil, errors.New("no token")
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest("GET", appConfig.Micropub.TokenEndpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Add(contentType, contentTypeWWWForm)
|
|
||||||
req.Header.Add("Authorization", bearerToken)
|
|
||||||
req.Header.Add("Accept", contentTypeJSON)
|
|
||||||
c := http.Client{
|
|
||||||
Timeout: time.Duration(10 * time.Second),
|
|
||||||
}
|
|
||||||
resp, err := c.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tokenRes := indieAuthTokenResponse{StatusCode: resp.StatusCode}
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(&tokenRes)
|
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &tokenRes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareHostnames(a string, allowed string) error {
|
func compareHostnames(a string, allowed string) error {
|
||||||
h1, err := url.Parse(a)
|
h1, err := url.Parse(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -0,0 +1,296 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cast"
|
||||||
|
)
|
||||||
|
|
||||||
|
// https://www.w3.org/TR/indieauth/
|
||||||
|
|
||||||
|
type indieAuthData struct {
|
||||||
|
Me string
|
||||||
|
ClientID string
|
||||||
|
RedirectURI string
|
||||||
|
State string
|
||||||
|
ResponseType string
|
||||||
|
Scopes []string
|
||||||
|
code string
|
||||||
|
token string
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func indieAuthAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
// Authentication / authorization request
|
||||||
|
r.ParseForm()
|
||||||
|
data := &indieAuthData{
|
||||||
|
Me: r.Form.Get("me"),
|
||||||
|
ClientID: r.Form.Get("client_id"),
|
||||||
|
RedirectURI: r.Form.Get("redirect_uri"),
|
||||||
|
State: r.Form.Get("state"),
|
||||||
|
ResponseType: r.Form.Get("response_type"),
|
||||||
|
}
|
||||||
|
if data.ResponseType == "" {
|
||||||
|
data.ResponseType = "id"
|
||||||
|
}
|
||||||
|
if scope := r.Form.Get("scope"); scope != "" {
|
||||||
|
data.Scopes = strings.Split(scope, " ")
|
||||||
|
}
|
||||||
|
if !isValidProfileURL(data.Me) || !isValidProfileURL(data.ClientID) || !isValidProfileURL(data.RedirectURI) {
|
||||||
|
http.Error(w, "me, client_id and redirect_uri need to by valid URLs", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.State == "" {
|
||||||
|
http.Error(w, "state must not be empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.ResponseType != "id" && data.ResponseType != "code" {
|
||||||
|
http.Error(w, "response_type must be empty or id or code", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.ResponseType == "code" && len(data.Scopes) < 1 {
|
||||||
|
http.Error(w, "scope is missing or empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render(w, "indieauthflow", &renderData{
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
} else if r.Method == http.MethodPost {
|
||||||
|
// Authentication verification
|
||||||
|
r.ParseForm()
|
||||||
|
data := &indieAuthData{
|
||||||
|
code: r.Form.Get("code"),
|
||||||
|
ClientID: r.Form.Get("client_id"),
|
||||||
|
RedirectURI: r.Form.Get("redirect_uri"),
|
||||||
|
}
|
||||||
|
valid, err := data.verifyAuthorization(true)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
http.Error(w, "Authentication not valid", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := &tokenResponse{
|
||||||
|
Me: data.Me,
|
||||||
|
}
|
||||||
|
w.Header().Add(contentType, contentTypeJSONUTF8)
|
||||||
|
err = json.NewEncoder(w).Encode(res)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Del(contentType)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidProfileURL(profileURL string) bool {
|
||||||
|
u, err := url.Parse(profileURL)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Missing: Check path
|
||||||
|
// Missing: Check single/double dot path
|
||||||
|
if u.Fragment != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.User.String() != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if u.Port() != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Missing: Check domain / ip
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func indieAuthAccept(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Authentication flow
|
||||||
|
r.ParseForm()
|
||||||
|
data := &indieAuthData{
|
||||||
|
Me: r.Form.Get("me"),
|
||||||
|
ClientID: r.Form.Get("client_id"),
|
||||||
|
RedirectURI: r.Form.Get("redirect_uri"),
|
||||||
|
State: r.Form.Get("state"),
|
||||||
|
ResponseType: r.Form.Get("response_type"),
|
||||||
|
Scopes: r.Form["scopes"],
|
||||||
|
time: time.Now(),
|
||||||
|
}
|
||||||
|
sha := sha1.New()
|
||||||
|
sha.Write([]byte(data.time.String() + data.Me + data.ClientID))
|
||||||
|
data.code = fmt.Sprintf("%x", sha.Sum(nil))
|
||||||
|
err := data.saveAuthorization()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, data.RedirectURI+"?code="+data.code+"&state="+data.State, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
|
TokenType string `json:"token_type,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
Me string `json:"me,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func indieAuthToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
// Token verification
|
||||||
|
data, err := verifyIndieAuthToken(r.Header.Get("Authorization"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid token or token not found", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
res := &tokenResponse{
|
||||||
|
Scope: strings.Join(data.Scopes, " "),
|
||||||
|
Me: data.Me,
|
||||||
|
ClientID: data.ClientID,
|
||||||
|
}
|
||||||
|
w.Header().Add(contentType, contentTypeJSONUTF8)
|
||||||
|
err = json.NewEncoder(w).Encode(res)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Del(contentType)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if r.Method == http.MethodPost {
|
||||||
|
r.ParseForm()
|
||||||
|
// Token Revocation
|
||||||
|
if r.Form.Get("action") == "revoke" {
|
||||||
|
revokeIndieAuthToken(r.Form.Get("token"))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Token request
|
||||||
|
if r.Form.Get("grant_type") == "authorization_code" {
|
||||||
|
data := &indieAuthData{
|
||||||
|
code: r.Form.Get("code"),
|
||||||
|
ClientID: r.Form.Get("client_id"),
|
||||||
|
RedirectURI: r.Form.Get("redirect_uri"),
|
||||||
|
Me: r.Form.Get("me"),
|
||||||
|
}
|
||||||
|
valid, err := data.verifyAuthorization(false)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
http.Error(w, "Authentication not valid", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data.Scopes) < 1 {
|
||||||
|
http.Error(w, "No scope", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.time = time.Now()
|
||||||
|
sha := sha1.New()
|
||||||
|
sha.Write([]byte(data.time.String() + data.Me + data.ClientID))
|
||||||
|
data.token = fmt.Sprintf("%x", sha.Sum(nil))
|
||||||
|
err = data.saveToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := &tokenResponse{
|
||||||
|
TokenType: "Bearer",
|
||||||
|
AccessToken: data.token,
|
||||||
|
Scope: strings.Join(data.Scopes, " "),
|
||||||
|
Me: data.Me,
|
||||||
|
}
|
||||||
|
w.Header().Add(contentType, contentTypeJSONUTF8)
|
||||||
|
err = json.NewEncoder(w).Encode(res)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Del(contentType)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data *indieAuthData) saveAuthorization() (err error) {
|
||||||
|
startWritingToDb()
|
||||||
|
defer finishWritingToDb()
|
||||||
|
_, err = appDb.Exec("insert into indieauthauth (time, code, me, client, redirect, scope) values (?, ?, ?, ?, ?, ?)", data.time.Unix(), data.code, data.Me, data.ClientID, data.RedirectURI, strings.Join(data.Scopes, " "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data *indieAuthData) verifyAuthorization(authentication bool) (valid bool, err error) {
|
||||||
|
// code valid for 600 seconds
|
||||||
|
if !authentication {
|
||||||
|
row := appDb.QueryRow("select code, me, client, redirect, scope from indieauthauth where time >= ? and code = ? and me = ? and client = ? and redirect = ?", time.Now().Unix()-600, data.code, data.Me, data.ClientID, data.RedirectURI)
|
||||||
|
scope := ""
|
||||||
|
err = row.Scan(&data.code, &data.Me, &data.ClientID, &data.RedirectURI, &scope)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if scope != "" {
|
||||||
|
data.Scopes = strings.Split(scope, " ")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row := appDb.QueryRow("select code, me, client, redirect from indieauthauth where time >= ? and code = ? and client = ? and redirect = ?", time.Now().Unix()-600, data.code, data.ClientID, data.RedirectURI)
|
||||||
|
err = row.Scan(&data.code, &data.Me, &data.ClientID, &data.RedirectURI)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valid = true
|
||||||
|
startWritingToDb()
|
||||||
|
defer finishWritingToDb()
|
||||||
|
_, err = appDb.Exec("delete from indieauthauth where code = ? or time < ?", data.code, time.Now().Unix()-600)
|
||||||
|
data.code = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (data *indieAuthData) saveToken() (err error) {
|
||||||
|
startWritingToDb()
|
||||||
|
defer finishWritingToDb()
|
||||||
|
_, err = appDb.Exec("insert into indieauthtoken (time, token, me, client, scope) values (?, ?, ?, ?, ?)", data.time.Unix(), data.token, data.Me, data.ClientID, strings.Join(data.Scopes, " "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyIndieAuthToken(token string) (data *indieAuthData, err error) {
|
||||||
|
token = strings.ReplaceAll(token, "Bearer ", "")
|
||||||
|
data = &indieAuthData{}
|
||||||
|
row := appDb.QueryRow("select time, token, me, client, scope from indieauthtoken where token = ?", token)
|
||||||
|
timeString := ""
|
||||||
|
scope := ""
|
||||||
|
err = row.Scan(&timeString, &data.token, &data.Me, &data.ClientID, &scope)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, errors.New("token not found")
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if scope != "" {
|
||||||
|
data.Scopes = strings.Split(scope, " ")
|
||||||
|
}
|
||||||
|
data.time = time.Unix(cast.ToInt64(timeString), 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeIndieAuthToken(token string) {
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startWritingToDb()
|
||||||
|
defer finishWritingToDb()
|
||||||
|
_, _ = appDb.Exec("delete from indieauthtoken where token=?", token)
|
||||||
|
return
|
||||||
|
}
|
|
@ -4,18 +4,9 @@
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<title>{{ .Blog.Title }}</title>
|
<title>{{ .Blog.Title }}</title>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<link rel="alternate"
|
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ .Data.First }}.rss"/>
|
||||||
type="application/rss+xml"
|
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ .Data.First }}.atom"/>
|
||||||
title="RSS"
|
<link rel="alternate" type="application/feed+json" title="JSONFeed" href="{{ .Data.First }}.json"/>
|
||||||
href="{{ .Data.First }}.rss"/>
|
|
||||||
<link rel="alternate"
|
|
||||||
type="application/atom+xml"
|
|
||||||
title="Atom"
|
|
||||||
href="{{ .Data.First }}.atom"/>
|
|
||||||
<link rel="alternate"
|
|
||||||
type="application/feed+json"
|
|
||||||
title="JSON Feed"
|
|
||||||
href="{{ .Data.First }}.json"/>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
{{ define "title" }}
|
||||||
|
<title>{{ string .Blog.Lang "indieauth" }} - {{ .Blog.Title }}</title>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "main" }}
|
||||||
|
<main>
|
||||||
|
<h1>{{ string .Blog.Lang "indieauth" }}</h1>
|
||||||
|
<form method="post" action="/indieauth/accept">
|
||||||
|
{{ if .Data.Scopes }}
|
||||||
|
<h3>{{ string .Blog.Lang "scopes" }}</h3>
|
||||||
|
<ul>
|
||||||
|
{{ range $i, $scope := .Data.Scopes }}
|
||||||
|
<li><input type="checkbox" name="scopes" value="{{ $scope }}" id="scope-{{ $scope }}" checked><label for="scope-{{ $scope }}">{{ $scope }}</label></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
<p><strong>me:</strong> {{ .Data.Me }}</p>
|
||||||
|
<p><strong>client_id:</strong> {{ .Data.ClientID }}</p>
|
||||||
|
<p><strong>redirect_uri:</strong> {{ .Data.RedirectURI }}</p>
|
||||||
|
<input type="hidden" name="redirect_uri" value="{{ .Data.RedirectURI }}">
|
||||||
|
<input type="hidden" name="state" value="{{ .Data.State }}">
|
||||||
|
<input type="hidden" id="client_id" name="client_id" value="{{ .Data.ClientID }}">
|
||||||
|
<input type="hidden" name="me" value="{{ .Data.Me }}">
|
||||||
|
<input type="hidden" name="response_type" value="{{ .Data.ResponseType }}">
|
||||||
|
<input type="submit" value="{{ string .Blog.Lang "authenticate" }}">
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "indieauthflow" }}
|
||||||
|
{{ template "base" . }}
|
||||||
|
{{ end }}
|
|
@ -2,13 +2,6 @@
|
||||||
{{ with micropub.Path }}
|
{{ with micropub.Path }}
|
||||||
<link rel="micropub" href="{{ . }}" />
|
<link rel="micropub" href="{{ . }}" />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ with micropub.AuthEndpoint }}
|
<link rel="authorization_endpoint" href="/indieauth" />
|
||||||
<link rel="authorization_endpoint" href="{{ . }}" />
|
<link rel="token_endpoint" href="/indieauth/token" />
|
||||||
{{ end }}
|
|
||||||
{{ with micropub.TokenEndpoint }}
|
|
||||||
<link rel="token_endpoint" href="{{ . }}" />
|
|
||||||
{{ end }}
|
|
||||||
{{ with micropub.Authn }}
|
|
||||||
<link href="{{ . }}" rel="me authn">
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -2,4 +2,7 @@ publishedon: "Published on"
|
||||||
updatedon: "Updated on"
|
updatedon: "Updated on"
|
||||||
next: "Next"
|
next: "Next"
|
||||||
prev: "Previous"
|
prev: "Previous"
|
||||||
view: "View"
|
view: "View"
|
||||||
|
authenticate: "Authenticate"
|
||||||
|
scopes: "Scopes"
|
||||||
|
indieauth: "IndieAuth"
|
Loading…
Reference in New Issue