├── .gitignore ├── go.mod ├── go.sum ├── README.md ├── Makefile ├── LICENSE ├── api └── main.go ├── web └── main.go └── auth └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | *.pem 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/acoshift/example-cross-origin-auth-api 2 | 3 | go 1.12 4 | 5 | require github.com/dgrijalva/jwt-go v3.2.0+incompatible 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 2 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # example-cross-origin-auth-api 2 | 3 | Example Cross-Origin Auth and API Servers 4 | 5 | ## Steps 6 | 7 | 1. Generate RSA for sign JWT 8 | 9 | `make generate` 10 | 11 | 1. Start Auth Server 12 | 13 | `make start-auth` 14 | 15 | 1. Start API Server 16 | 17 | `make start-api` 18 | 19 | 1. Start Web Server 20 | 21 | `make start-web` 22 | 23 | 1. Browse web at http://localhost:8080 24 | 25 | 1. Sign In 26 | 27 | 1. You will sign in to localhost:8081, but profile api will called from localhost:8082 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | @echo "Example Cross-Origin Auth and API Servers" 3 | @echo "Commands:" 4 | @echo "\tgenerate - generate RSA for signing" 5 | @echo "\tstart-auth - start auth server at origin http://localhost:8081" 6 | @echo "\tstart-api - start api server at origin http://localhost:8082" 7 | @echo "\tstart-web - start web server at origin http://localhost:8080" 8 | 9 | start-auth: 10 | go run ./auth 11 | 12 | start-api: 13 | go run ./api 14 | 15 | start-web: 16 | go run ./web 17 | 18 | generate: 19 | openssl genrsa -out private-key.pem 2048 20 | openssl rsa -in private-key.pem -pubout > public-key.pem 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thanatat Tamtan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rsa" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/dgrijalva/jwt-go" 14 | ) 15 | 16 | func main() { 17 | pubKey, _ := ioutil.ReadFile("public-key.pem") 18 | publicKey, err := jwt.ParseRSAPublicKeyFromPEM(pubKey) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | api := &api{ 24 | PublicKey: publicKey, 25 | } 26 | log.Println("API Server started at :8082") 27 | http.ListenAndServe(":8082", api) 28 | } 29 | 30 | type api struct { 31 | once sync.Once 32 | mux *http.ServeMux 33 | 34 | PublicKey *rsa.PublicKey 35 | } 36 | 37 | type errorResponse struct { 38 | Error string `json:"error"` 39 | } 40 | 41 | func (h *api) ServeHTTP(w http.ResponseWriter, r *http.Request) { 42 | h.once.Do(func() { 43 | h.mux = http.NewServeMux() 44 | h.mux.HandleFunc("/profile", h.profile) 45 | }) 46 | 47 | // always return json 48 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 49 | 50 | // general headers 51 | w.Header().Set("X-XSS-Protection", "1; mode=block") 52 | w.Header().Set("X-Frame-Options", "deny") 53 | w.Header().Set("X-Content-Type-Options", "nosniff") 54 | 55 | // start: CORS 56 | 57 | // allow all origins 58 | w.Header().Set("Access-Control-Allow-Origin", "*") 59 | // not allow credentials 60 | 61 | if r.Method == http.MethodOptions { 62 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") 63 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 64 | w.Header().Set("Access-Control-Max-Age", "3600") 65 | w.Header().Set("Content-Type", "text/plain") 66 | 67 | // in-case we run behind cached reverse proxy 68 | w.Header().Add("Vary", "Origin") 69 | w.Header().Add("Vary", "Access-Control-Request-Method") 70 | w.Header().Add("Vary", "Access-Control-Request-Headers") 71 | 72 | w.WriteHeader(http.StatusNoContent) 73 | return 74 | } 75 | 76 | // end: CORS 77 | 78 | h.mux.ServeHTTP(w, r) 79 | } 80 | 81 | func (h *api) profile(w http.ResponseWriter, r *http.Request) { 82 | subject := h.parseToken(r) 83 | if subject == "" { 84 | w.WriteHeader(http.StatusUnauthorized) 85 | json.NewEncoder(w).Encode(errorResponse{"Unauthorized"}) 86 | return 87 | } 88 | 89 | json.NewEncoder(w).Encode(struct { 90 | ID string `json:"id"` 91 | Name string `json:"name"` 92 | }{subject, "Nakano Miku"}) 93 | } 94 | 95 | // parseToken parses token from request and return user's id 96 | func (h *api) parseToken(r *http.Request) string { 97 | auth := r.Header.Get("Authorization") 98 | if len(auth) < 7 { 99 | return "" 100 | } 101 | 102 | if !strings.EqualFold(auth[:7], "bearer ") { 103 | return "" 104 | } 105 | 106 | tokenStr := auth[7:] 107 | if tokenStr == "" { 108 | return "" 109 | } 110 | 111 | token, err := jwt.ParseWithClaims(tokenStr, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { 112 | if token.Method != jwt.SigningMethodRS256 { 113 | return nil, fmt.Errorf("invalid method") 114 | } 115 | return h.PublicKey, nil 116 | }) 117 | if err != nil { 118 | return "" 119 | } 120 | if !token.Valid { 121 | return "" 122 | } 123 | 124 | sub, _ := token.Claims.(jwt.MapClaims)["sub"].(string) 125 | return sub 126 | } 127 | -------------------------------------------------------------------------------- /web/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | log.Println("Web Server started at :8080") 11 | http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | w.Header().Set("X-XSS-Protection", "1; mode=block") 13 | w.Header().Set("X-Frame-Options", "deny") 14 | w.Header().Set("X-Content-Type-Options", "nosniff") 15 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 16 | io.WriteString(w, index) 17 | })) 18 | } 19 | 20 | // language=HTML 21 | const index = ` 22 | 23 | Example Cross-Origin Auth and API Servers 24 |

Example Cross-Origin Auth and API Servers

25 | 26 | 27 | 45 | 46 | 187 | ` 188 | -------------------------------------------------------------------------------- /auth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "encoding/base64" 7 | "encoding/json" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/dgrijalva/jwt-go" 16 | ) 17 | 18 | func main() { 19 | rsaPrivKey, _ := ioutil.ReadFile("private-key.pem") 20 | privKey, err := jwt.ParseRSAPrivateKeyFromPEM(rsaPrivKey) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | auth := &auth{ 26 | PrivateKey: privKey, 27 | } 28 | log.Println("Auth Server started at :8081") 29 | http.ListenAndServe(":8081", auth) 30 | } 31 | 32 | type auth struct { 33 | once sync.Once 34 | mux *http.ServeMux 35 | 36 | sessionStorage sync.Map 37 | PrivateKey *rsa.PrivateKey 38 | } 39 | 40 | type sessionData struct { 41 | UserID string 42 | } 43 | 44 | type errorResponse struct { 45 | Error string `json:"error"` 46 | } 47 | 48 | type successResponse struct { 49 | Success bool `json:"success"` 50 | } 51 | 52 | func (h *auth) ServeHTTP(w http.ResponseWriter, r *http.Request) { 53 | h.once.Do(func() { 54 | h.mux = http.NewServeMux() 55 | h.mux.HandleFunc("/signin", h.signIn) 56 | h.mux.HandleFunc("/signout", h.signOut) 57 | h.mux.HandleFunc("/token", h.token) 58 | }) 59 | 60 | // always return json 61 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 62 | 63 | // general headers 64 | w.Header().Set("X-XSS-Protection", "1; mode=block") 65 | w.Header().Set("X-Frame-Options", "deny") 66 | w.Header().Set("X-Content-Type-Options", "nosniff") 67 | 68 | // start: CSRF protection 69 | 70 | // validate target 71 | if r.Host != "localhost:8081" { 72 | w.WriteHeader(http.StatusForbidden) 73 | json.NewEncoder(w).Encode(errorResponse{"CSRF Protection: Invalid host"}) 74 | return 75 | } 76 | 77 | // validate origin, allow only http://localhost:8080 78 | if r.Header.Get("Origin") != "http://localhost:8080" { 79 | w.WriteHeader(http.StatusForbidden) 80 | json.NewEncoder(w).Encode(errorResponse{"CSRF Protection: Invalid origin"}) 81 | return 82 | } 83 | 84 | // validate referer 85 | if !strings.HasPrefix(r.Header.Get("Referer"), "http://localhost:8080/") { 86 | w.WriteHeader(http.StatusForbidden) 87 | json.NewEncoder(w).Encode(errorResponse{"CSRF Protection: Invalid referer"}) 88 | return 89 | } 90 | 91 | // end: CSRF protection 92 | 93 | // start: CORS 94 | // allow only http://localhost:8080 95 | w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8080") 96 | w.Header().Set("Access-Control-Allow-Credentials", "true") 97 | 98 | if r.Method == http.MethodOptions { 99 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST") 100 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With") 101 | w.Header().Set("Access-Control-Max-Age", "3600") 102 | w.Header().Set("Content-Type", "text/plain") 103 | 104 | // in-case we run behind cached reverse proxy 105 | w.Header().Add("Vary", "Origin") 106 | w.Header().Add("Vary", "Access-Control-Request-Method") 107 | w.Header().Add("Vary", "Access-Control-Request-Headers") 108 | 109 | w.WriteHeader(http.StatusNoContent) // some old browsers might need status 200 or error 110 | return 111 | } 112 | 113 | // end: CORS 114 | 115 | // additional CSRF, validate ajax request 116 | if r.Header.Get("X-Requested-With") != "XMLHttpRequest" { 117 | w.WriteHeader(http.StatusForbidden) 118 | json.NewEncoder(w).Encode(errorResponse{"CSRF Protection: Allow only AJAX requests"}) 119 | return 120 | } 121 | 122 | h.mux.ServeHTTP(w, r) 123 | } 124 | 125 | func (h *auth) signIn(w http.ResponseWriter, r *http.Request) { 126 | // allow only post 127 | if r.Method != http.MethodPost { 128 | w.Header().Set("Allow", "POST") 129 | w.WriteHeader(http.StatusMethodNotAllowed) 130 | json.NewEncoder(w).Encode(errorResponse{"Method not allowed"}) 131 | return 132 | } 133 | 134 | username := r.PostFormValue("username") 135 | password := r.PostFormValue("password") 136 | if username != "miku" || password != "nakano" { // ❤️ Nakano Miku 137 | w.WriteHeader(http.StatusUnauthorized) 138 | json.NewEncoder(w).Encode(errorResponse{"Invalid credentials"}) 139 | return 140 | } 141 | 142 | sessID := generateSessionID() 143 | h.sessionStorage.Store(sessID, sessionData{ 144 | UserID: "miku-001", 145 | }) 146 | 147 | setSessionCookie(w, sessID) 148 | json.NewEncoder(w).Encode(successResponse{true}) 149 | } 150 | 151 | func (h *auth) signOut(w http.ResponseWriter, r *http.Request) { 152 | // allow only post 153 | if r.Method != http.MethodPost { 154 | w.Header().Set("Allow", "POST") 155 | w.WriteHeader(http.StatusMethodNotAllowed) 156 | json.NewEncoder(w).Encode(errorResponse{"Method not allowed"}) 157 | return 158 | } 159 | 160 | // sign out always success 161 | json.NewEncoder(w).Encode(successResponse{true}) 162 | 163 | sessID := h.getSessionID(w, r) 164 | if sessID != "" { 165 | h.sessionStorage.Delete(sessID) 166 | } 167 | } 168 | 169 | func (h *auth) token(w http.ResponseWriter, r *http.Request) { 170 | // allow only get 171 | if r.Method != http.MethodGet { 172 | w.Header().Set("Allow", "GET") 173 | w.WriteHeader(http.StatusMethodNotAllowed) 174 | json.NewEncoder(w).Encode(errorResponse{"Method not allowed"}) 175 | return 176 | } 177 | 178 | sessID := h.getSessionID(w, r) 179 | if sessID == "" { 180 | w.WriteHeader(http.StatusUnauthorized) 181 | json.NewEncoder(w).Encode(errorResponse{"Unauthorized"}) 182 | return 183 | } 184 | 185 | sessInf, ok := h.sessionStorage.Load(sessID) 186 | if !ok { 187 | w.WriteHeader(http.StatusUnauthorized) 188 | json.NewEncoder(w).Encode(errorResponse{"Unauthorized"}) 189 | return 190 | } 191 | sess := sessInf.(sessionData) 192 | 193 | expIn := 5 * time.Minute 194 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ 195 | "sub": sess.UserID, 196 | "exp": time.Now().Add(expIn).Unix(), 197 | }) 198 | tokenStr, err := token.SignedString(h.PrivateKey) 199 | if err != nil { 200 | log.Println(err) 201 | w.WriteHeader(http.StatusInternalServerError) 202 | json.NewEncoder(w).Encode(errorResponse{"Can not sign token"}) 203 | return 204 | } 205 | json.NewEncoder(w).Encode(struct { 206 | AccessToken string `json:"access_token"` 207 | ExpiresIn int64 `json:"expires_in"` 208 | }{tokenStr, int64(expIn / time.Second)}) 209 | } 210 | 211 | func (h *auth) getSessionID(w http.ResponseWriter, r *http.Request) string { 212 | cookie, err := r.Cookie("sess") 213 | if err != nil { 214 | // cookie not found 215 | return "" 216 | } 217 | 218 | sessID := cookie.Value 219 | 220 | // rolling session expiration 221 | if sessID != "" { 222 | setSessionCookie(w, sessID) 223 | } 224 | 225 | return sessID 226 | } 227 | 228 | func generateSessionID() string { 229 | var b [16]byte 230 | rand.Read(b[:]) 231 | return base64.RawURLEncoding.EncodeToString(b[:]) 232 | } 233 | 234 | func setSessionCookie(w http.ResponseWriter, sessID string) { 235 | http.SetCookie(w, &http.Cookie{ 236 | Name: "sess", 237 | Path: "/", 238 | Value: sessID, 239 | MaxAge: 3600, // 1 hr 240 | // Secure: true, // for https 241 | HttpOnly: true, // not allow JavaScript to read session cookie 242 | }) 243 | } 244 | --------------------------------------------------------------------------------