An experimental IndieWeb site built in Go.
at main 246 lines 6.8 kB view raw
1package services 2 3import ( 4 "context" 5 "crypto/sha256" 6 "crypto/subtle" 7 "encoding/json" 8 "errors" 9 "net/http" 10 "net/url" 11 "os" 12 "strings" 13 "time" 14 15 "github.com/puregarlic/space/html/layouts" 16 "github.com/puregarlic/space/html/pages" 17 "github.com/puregarlic/space/storage" 18 19 "github.com/aidarkhanov/nanoid" 20 "github.com/golang-jwt/jwt/v5" 21 "go.hacdias.com/indielib/indieauth" 22) 23 24type IndieAuth struct { 25 ProfileURL string 26 Server *indieauth.Server 27} 28 29func (i *IndieAuth) storeAuthorization(req *indieauth.AuthenticationRequest) string { 30 code := nanoid.New() 31 32 storage.AuthCache().Set(code, req, 0) 33 34 return code 35} 36 37type CustomTokenClaims struct { 38 Scopes []string `json:"scopes"` 39 jwt.RegisteredClaims 40} 41 42type contextKey string 43 44const ( 45 scopesContextKey contextKey = "scopes" 46) 47 48func (i *IndieAuth) HandleAuthGET(w http.ResponseWriter, r *http.Request) { 49 req, err := i.Server.ParseAuthorization(r) 50 if err != nil { 51 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 52 return 53 } 54 55 app, _ := i.Server.DiscoverApplicationMetadata(r.Context(), req.ClientID) 56 57 nonceId, nonce := nanoid.New(), nanoid.New() 58 storage.NonceCache().Set(nonceId, nonce, 0) 59 60 layouts.RenderDefault("authorize", pages.Auth(req, app, nonceId, nonce)).ServeHTTP(w, r) 61} 62 63func (i *IndieAuth) HandleAuthPOST(w http.ResponseWriter, r *http.Request) { 64 i.authorizationCodeExchange(w, r, false) 65} 66 67func (i *IndieAuth) HandleToken(w http.ResponseWriter, r *http.Request) { 68 if r.Method != http.MethodPost { 69 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 70 return 71 } 72 73 if r.Form.Get("grant_type") == "refresh_token" { 74 // NOTE: this server does not implement refresh tokens. 75 // https://indieauth.spec.indieweb.org/#refresh-tokens 76 w.WriteHeader(http.StatusNotImplemented) 77 return 78 } 79 80 i.authorizationCodeExchange(w, r, true) 81} 82 83type tokenResponse struct { 84 Me string `json:"me"` 85 AccessToken string `json:"access_token,omitempty"` 86 TokenType string `json:"token_type,omitempty"` 87 Scope string `json:"scope,omitempty"` 88 ExpiresIn int64 `json:"expires_in,omitempty"` 89} 90 91func (i *IndieAuth) authorizationCodeExchange(w http.ResponseWriter, r *http.Request, withToken bool) { 92 if err := r.ParseForm(); err != nil { 93 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", err.Error()) 94 return 95 } 96 97 // t := s.getAuthorization(r.Form.Get("code")) 98 req, present := storage.AuthCache().GetAndDelete(r.Form.Get("code")) 99 if !present { 100 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", "invalid authorization") 101 return 102 } 103 authRequest := req.Value() 104 105 err := i.Server.ValidateTokenExchange(authRequest, r) 106 if err != nil { 107 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", err.Error()) 108 return 109 } 110 111 response := &tokenResponse{ 112 Me: i.ProfileURL, 113 } 114 115 scopes := authRequest.Scopes 116 117 if withToken { 118 now := time.Now() 119 expiresAt := now.Add(15 * time.Minute) 120 claims := CustomTokenClaims{ 121 scopes, 122 jwt.RegisteredClaims{ 123 ExpiresAt: jwt.NewNumericDate(expiresAt), 124 IssuedAt: jwt.NewNumericDate(now), 125 NotBefore: jwt.NewNumericDate(now), 126 }, 127 } 128 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 129 130 secret := os.Getenv("JWT_SECRET") 131 jwt, err := token.SignedString([]byte(secret)) 132 if err != nil { 133 panic(err) 134 } 135 136 response.AccessToken = jwt 137 response.TokenType = "Bearer" 138 response.ExpiresIn = int64(time.Until(expiresAt).Seconds()) 139 response.Scope = strings.Join(scopes, " ") 140 } 141 142 // An actual server may want to include the "profile" in the response if the 143 // scope "profile" is included. 144 SendJSON(w, http.StatusOK, response) 145} 146 147func (i *IndieAuth) HandleAuthApproval(w http.ResponseWriter, r *http.Request) { 148 id := r.FormValue("nonce_id") 149 nonce := r.FormValue("nonce") 150 151 stored, ok := storage.NonceCache().GetAndDelete(id) 152 if !ok { 153 SendErrorJSON(w, http.StatusBadRequest, "bad_request", "nonce does not match") 154 } else if stored.Value() != nonce { 155 SendErrorJSON(w, http.StatusBadRequest, "bad_request", "nonce does not match") 156 } 157 158 req, err := i.Server.ParseAuthorization(r) 159 if err != nil { 160 SendErrorJSON(w, http.StatusBadRequest, "invalid_request", err.Error()) 161 return 162 } 163 164 code := i.storeAuthorization(req) 165 166 // Redirect to client callback. 167 query := url.Values{} 168 query.Set("code", code) 169 query.Set("state", req.State) 170 http.Redirect(w, r, req.RedirectURI+"?"+query.Encode(), http.StatusFound) 171} 172 173func MustAuth(next http.Handler) http.Handler { 174 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 175 tokenStr := r.Header.Get("Authorization") 176 tokenStr = strings.TrimPrefix(tokenStr, "Bearer") 177 tokenStr = strings.TrimSpace(tokenStr) 178 179 if len(tokenStr) <= 0 { 180 SendErrorJSON(w, http.StatusUnauthorized, "invalid_request", "no credentials") 181 return 182 } 183 184 token, err := jwt.ParseWithClaims(tokenStr, &CustomTokenClaims{}, func(t *jwt.Token) (interface{}, error) { 185 return []byte(os.Getenv("JWT_SECRET")), nil 186 }) 187 188 if err != nil { 189 SendErrorJSON(w, http.StatusUnauthorized, "invalid_request", "invalid token") 190 return 191 } else if claims, ok := token.Claims.(*CustomTokenClaims); ok { 192 ctx := context.WithValue(r.Context(), scopesContextKey, claims.Scopes) 193 next.ServeHTTP(w, r.WithContext(ctx)) 194 return 195 } else { 196 SendErrorJSON(w, http.StatusUnauthorized, "invalid_request", "malformed claims") 197 return 198 } 199 }) 200} 201 202func MustBasicAuth(next http.Handler) http.Handler { 203 user, ok := os.LookupEnv("ADMIN_USERNAME") 204 if !ok { 205 panic(errors.New("ADMIN_USERNAME is not set, cannot start")) 206 } 207 208 pass, ok := os.LookupEnv("ADMIN_PASSWORD") 209 if !ok { 210 panic(errors.New("ADMIN_PASSWORD is not set, cannot start")) 211 } 212 213 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 214 username, password, ok := r.BasicAuth() 215 if ok { 216 usernameHash := sha256.Sum256([]byte(username)) 217 passwordHash := sha256.Sum256([]byte(password)) 218 expectedUsernameHash := sha256.Sum256([]byte(user)) 219 expectedPasswordHash := sha256.Sum256([]byte(pass)) 220 221 usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1) 222 passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1) 223 224 if usernameMatch && passwordMatch { 225 next.ServeHTTP(w, r) 226 return 227 } 228 } 229 230 w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) 231 http.Error(w, "Unauthorized", http.StatusUnauthorized) 232 }) 233} 234 235func SendJSON(w http.ResponseWriter, code int, data interface{}) { 236 w.Header().Set("Content-Type", "application/json; charset=utf-8") 237 w.WriteHeader(code) 238 _ = json.NewEncoder(w).Encode(data) 239} 240 241func SendErrorJSON(w http.ResponseWriter, code int, err, errDescription string) { 242 SendJSON(w, code, map[string]string{ 243 "error": err, 244 "error_description": errDescription, 245 }) 246}