dewrich closed pull request #567: API GW phase 0 (replaces #551, depends on #544) URL: https://github.com/apache/incubator-trafficcontrol/pull/567
This is a PR merged from a forked repository. As GitHub hides the original diff on merge, it is displayed below for the sake of provenance: As this is a foreign pull request (from a fork), the diff is supplied below (as it won't show otherwise due to GitHub magic): diff --git a/traffic_ops/experimental/auth/README.md b/traffic_ops/experimental/auth/README.md index 1e86579af..0470425a7 100644 --- a/traffic_ops/experimental/auth/README.md +++ b/traffic_ops/experimental/auth/README.md @@ -1,9 +1,36 @@ -A simple authentication server written in go that authenticates user agains the `tm_user` table and returns a jwt representing the user, incl. its API access capabilities, derived from the user's role. +A simple authentication server written in go that authenticates user against the `tm_user` table and returns a jwt access token representing the user, incl. its API access capabilities, derived from the user's role. -* To run: -`go run auth.go auth.config my-secret` -`secret` is used for jwt signing +Note that the authentication server is designed to work in conjunction with the "webfront" server, that acts as an API GW. Once you obtain an access token from the auth service you can use it with "webfront" to authenticate your API calls. See [webfront documentation](../webfront/README.md) -* To login: -`curl --insecure -X POST -Lkvs --header "Content-Type:application/json" https://localhost:9004/login -d'{"username":"username", "password":"password"}'` +**Legacy TO support** + +Currently, the Mojo app reqires a valid Mojo token. As long as the Mojo code use a Mojo token for authorization, the Auth server and the API GW handle legacy authorization in the following way + +* Upon every sucessful login, the auth server performs additional login against the Mojo app and recieves a Mojo token +* The Mojo token is added as a claim to the user's JWT +* Upon successive API calls, the API GW pulls the claim from the JWT and set a "mojolicious" cookie on the request + +In addition, if a request contains a "mojolicious" cookie instead of an authentication bearer token, the API GW bypass JWT authentication. +This is to support legacy code that access TO API without logging in via the new auth server. + +**Before you begin** + +You will need to generate a server certificate for ssl connections against webfront. In the project directory, run +~~~~ +openssl req -x509 -sha256 -nodes -days 3650 -newkey rsa:2048 -keyout server.key -out server.crt +~~~~ + +**Run the server** + + `go run auth.go auth.config my-secret` + + `my-secret` is used for jwt signing + +**Perform a login call (to get a token)** + + `curl --insecure -X POST -Lkvs --header "Content-Type:application/json" https://localhost:9004/login -d'{"username":"username", "password":"password"}'` + +See [webfront documentation](../webfront/README.md) for using this token in your API calls against the webfront server. + +Note that webfront forwad login calls to the auth server. In real-world scanarios login calls are done against webfront (API GW) and not directly against the auth server. Login calls via webfront do not require a token \ No newline at end of file diff --git a/traffic_ops/experimental/auth/auth.config b/traffic_ops/experimental/auth/auth.config index 2c4dfa5d7..9a077a490 100644 --- a/traffic_ops/experimental/auth/auth.config +++ b/traffic_ops/experimental/auth/auth.config @@ -1,8 +1,11 @@ { - "db-name": "to_development", - "db-user": "username", - "db-password": "password", - "db-server": "localhost", - "db-port": 5432, - "listen-port": 9004 -} \ No newline at end of file + "db-name": "to_development", + "db-user": "username", + "db-password": "password", + "db-server": "localhost", + "db-port": 5432, + "listen-port": 9004, + "crt-file": "server.crt", + "key-file": "server.key", + "legacy-login-url": "http://localhost:3000/api/1.2/user/login" +} diff --git a/traffic_ops/experimental/auth/auth.go b/traffic_ops/experimental/auth/auth.go index 8ffbc5c13..9bc574740 100644 --- a/traffic_ops/experimental/auth/auth.go +++ b/traffic_ops/experimental/auth/auth.go @@ -16,7 +16,7 @@ package main import ( - "crypto/sha1" + "bytes" "encoding/json" "fmt" jwt "github.com/dgrijalva/jwt-go" @@ -33,12 +33,15 @@ import ( // Config holds the configuration of the server. type Config struct { - DbName string `json:"db-name"` - DbUser string `json:"db-user"` - DbPassword string `json:"db-password"` - DbServer string `json:"db-server"` - DbPort uint `json:"db-port"` - ListenPort uint `json:"listen-port"` + DbName string `json:"db-name"` + DbUser string `json:"db-user"` + DbPassword string `json:"db-password"` + DbServer string `json:"db-server"` + DbPort uint `json:"db-port"` + ListenPort uint `json:"listen-port"` + CrtFile string `json:"crt-file"` + KeyFile string `json:"key-file"` + LegacyLoginURL string `json:"legacy-login-url"` } type Login struct { @@ -46,13 +49,27 @@ type Login struct { Password string `json:"password"` } +type LegacyLogin struct { + Username string `json:"u"` + Password string `json:"p"` +} + type TmUser struct { - Role uint `db:"role"` + UserId int `db:"id"` Password string `db:"local_passwd"` } +type UserRole struct { + RoleId int `db:"role_id"` +} + +type Capability struct { + CapName string `db:"cap_name"` +} + type Claims struct { - Capabilities []string `json:"cap"` + Capabilities []string `json:"cap"` + LegacyCookie string `json:"legacy-cookie"` // LEGACY: The legacy cookie to be passed to API GW jwt.StandardClaims } @@ -66,12 +83,15 @@ var Logger *log.Logger func printUsage() { exampleConfig := `{ - "db_name": "to_development", - "db_user": "username", - "db_password": "password", - "db_server": "localhost", - "db_port": 5432, - "listen_port": 9004 + "db_name": "to_development", + "db_user": "username", + "db_password": "password", + "db_server": "localhost", + "db_port": 5432, + "listen_port": 9004, + "crt-file": "server.crt", + "key-file": "server.key", + "legacy_to_login": "http://localhost:3000/api/1.2/user/login" }` fmt.Println("Usage: " + path.Base(os.Args[0]) + " config-file secret") fmt.Println("") @@ -107,18 +127,18 @@ func main() { return } + handler, _ := makeHandler(&config) http.HandleFunc("/login", handler) - if _, err := os.Stat("server.pem"); os.IsNotExist(err) { - Logger.Fatal("server.pem file not found") + if _, err := os.Stat(config.CrtFile); os.IsNotExist(err) { + Logger.Fatalf("%s file not found", config.CrtFile) } - - if _, err := os.Stat("server.key"); os.IsNotExist(err) { - Logger.Fatal("server.key file not found") + if _, err := os.Stat(config.KeyFile); os.IsNotExist(err) { + Logger.Fatalf("%s file not found", config.KeyFile) } Logger.Printf("Starting server on port %d...", config.ListenPort) - Logger.Fatal(http.ListenAndServeTLS(":" + strconv.Itoa(int(config.ListenPort)), "server.pem", "server.key", nil)) + Logger.Fatal(http.ListenAndServeTLS(":" + strconv.Itoa(int(config.ListenPort)), config.CrtFile, config.KeyFile, nil)) } func InitializeDatabase(username, password, dbname, server string, port uint) (*sqlx.DB, error) { @@ -132,83 +152,202 @@ func InitializeDatabase(username, password, dbname, server string, port uint) (* return db, nil } -func handler(w http.ResponseWriter, r *http.Request) { - - Logger.Println(r.Method, r.URL.Scheme, r.Host, r.URL.RequestURI()) - - if r.Method == "POST" { - var login Login - tmUserlist := []TmUser{} - body, err := ioutil.ReadAll(r.Body) - if err != nil { - Logger.Println("Error reading body: ", err.Error()) - http.Error(w, "Error reading body: "+err.Error(), http.StatusBadRequest) - return - } - - err = json.Unmarshal(body, &login) - if err != nil { - Logger.Println("Invalid JSON: ", err.Error()) - http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - - stmt, err := db.PrepareNamed("SELECT role,local_passwd FROM tm_user WHERE username=:username") - if err != nil { - Logger.Println("Database error: ", err.Error()) - http.Error(w, "Database error: "+err.Error(), http.StatusInternalServerError) - return - } - - err = stmt.Select(&tmUserlist, login) - if err != nil { - Logger.Println("Database error: ", err.Error()) - http.Error(w, "Database error: "+err.Error(), http.StatusInternalServerError) - return - } - - hasher := sha1.New() - hasher.Write([]byte(login.Password)) - hashedPassword := fmt.Sprintf("%x", hasher.Sum(nil)) +func LegacyTOLogin(login Login, legacyLoginURL string, w http.ResponseWriter) (*http.Response, error) { - if len(tmUserlist) == 0 || tmUserlist[0].Password != string(hashedPassword) { - Logger.Printf("Invalid username/password, username %s", login.Username) - http.Error(w, "Invalid username/password", http.StatusUnauthorized) - return - } + // TODO(amiry) - Legacy token expiration should be longer than JWT expiration - Logger.Printf("User %s authenticated", login.Username) + legacyLogin := LegacyLogin{ login.Username, login.Password } - claims := Claims { - []string{"read-ds", "write-ds", "read-cg"}, // TODO(amiry) - Adding hardcoded capabilities as a POC. - // Need to read from TO role tables when tables are ready - jwt.StandardClaims { - Subject: login.Username, - ExpiresAt: time.Now().Add(time.Hour * 24).Unix(), // TODO(amiry) - We will need to use shorter expiration, - // and use refresh tokens to extend access - }, - } + body, err := json.Marshal(legacyLogin) + if err != nil { + Logger.Println("JSON marshal error: ", err.Error()) + return nil, err + } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + req, err := http.NewRequest("POST", legacyLoginURL, bytes.NewBuffer(body)) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + Logger.Println("Legacy Login error: ", err.Error(), " Legacy URL: ", legacyLoginURL) + return nil, err; + } - tokenString, err := token.SignedString([]byte(os.Args[2])) - if err != nil { - Logger.Println(err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + return resp, err +} - js, err := json.Marshal(TokenResponse{Token: tokenString}) - if err != nil { - Logger.Println(err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) +func makeHandler(config *Config) (func(http.ResponseWriter, *http.Request), error) { + + return func (w http.ResponseWriter, r *http.Request) { + + Logger.Println(r.Method, r.URL.Scheme, r.Host, r.URL.RequestURI()) + + if r.Method == "POST" { + + var login Login + tmUserList := []TmUser{} + userRoleList := []UserRole{} + capList := []Capability{} + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + Logger.Printf("Error reading request body: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + err = json.Unmarshal(body, &login) + if err != nil { + Logger.Printf("JSON error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Get the user id and the password from tm_user, in order to validate the user's password + stmt, err := db.PrepareNamed("SELECT id,local_passwd FROM tm_user WHERE username=:username") + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = stmt.Select(&tmUserList, login) + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + /* + hasher := sha1.New() + hasher.Write([]byte(login.Password)) + hashedPassword := fmt.Sprintf("%x", hasher.Sum(nil)) + + if len(tmUserList) == 0 || tmUserList[0].Password != string(hashedPassword) { + Logger.Printf("Invalid username/password. Username=%s]", login.Username) + http.Error(w, "Invalid username/password", http.StatusUnauthorized) + return + } + */ + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // LEGACY: Perform login against legacy TO. This is required until AAA is disabled in TO + legacyResp, err := LegacyTOLogin(login, config.LegacyLoginURL, w); + if err != nil { + Logger.Printf("Traffic Ops login error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return; + } + + if legacyResp.StatusCode != http.StatusOK { + Logger.Printf("Invalid username/password. Username=%s]", login.Username) + http.Error(w, "Invalid username/password", http.StatusUnauthorized) + return + } + + legacyCookies := legacyResp.Cookies() + + if (legacyCookies == nil) || (len(legacyCookies) != 1) || (legacyCookies[0].Name != "mojolicious") { + Logger.Printf("Error parsing Traffic Ops response cookies. Cookies: %v ", legacyCookies) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + Logger.Printf("LEGACY LOGIN TOKEN: %s %s %s", legacyCookies[0].Name, legacyCookies[0].Value, legacyCookies[0].Expires) + + // LEGACY: End + ///////////////////////////////////////////////////////////////////////////////////////////////// + + + // We have validated the user's password, now lets get the user's roles + stmt, err = db.PrepareNamed("SELECT role_id FROM user_role WHERE user_id=:id") + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = stmt.Select(&userRoleList, tmUserList[0]) + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + rolesIds := []int{} + for _, elem := range userRoleList { + rolesIds = append(rolesIds, elem.RoleId) + } + + capabilities := []string{} + + if len(rolesIds) > 0 { + + // Get user's capabilities according to the user's roles + sql, args, err := sqlx.In("SELECT cap_name FROM role_capability WHERE role_id IN (?)", rolesIds) + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // Replace the "?" bindvar syntax with DB specific syntax ($1, $2, ... for PostgreSQL) + sql = db.Rebind(sql) + + stmt1, err := db.Preparex(sql) + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = stmt1.Select(&capList, args...) + if err != nil { + Logger.Printf("DB error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + for _, elem := range capList { + capabilities = append(capabilities, elem.CapName) + } + } + + Logger.Printf("User %s authenticated. Role Ids %v. Capabilities %v", login.Username, rolesIds, capabilities) + + claims := Claims { + + capabilities, // Set capabilities + legacyCookies[0].String(), // LEGACY: Set legacy cookie + + jwt.StandardClaims { + Subject: login.Username, + ExpiresAt: time.Now().Add(time.Hour * 24).Unix(), // TODO(amiry) - We will need to use shorter expiration, + // and use refresh tokens to extend access. + // Expiration time should be configurable. + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tokenSignedString, err := token.SignedString([]byte(os.Args[2])) + if err != nil { + Logger.Printf("JWT error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + js, err := json.Marshal(TokenResponse{Token: tokenSignedString}) + if err != nil { + Logger.Printf("JWT error: %s", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(js) return } - w.Header().Set("Content-Type", "application/json") - w.Write(js) - return - } - - http.Error(w, r.Method+" "+r.URL.Path+" not valid for this microservice", http.StatusNotFound) -} \ No newline at end of file + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + }, nil +} diff --git a/traffic_ops/experimental/auth/server.pem b/traffic_ops/experimental/auth/server.pem deleted file mode 100644 index c09c2ca87..000000000 --- a/traffic_ops/experimental/auth/server.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDbzCCAlegAwIBAgIJAJozT3F7lmQwMA0GCSqGSIb3DQEBCwUAME4xCzAJBgNV -BAYTAlVTMQswCQYDVQQIDAJDTzEPMA0GA1UEBwwGRGVudmVyMSEwHwYDVQQKDBhJ -bnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYwMzI2MjIyODExWhcNMjYwMzI0 -MjIyODExWjBOMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ08xDzANBgNVBAcMBkRl -bnZlcjEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0g8TSrCJLfzMxzCB+zLo19NIrOAA2kR2 -5vi8Hu+aM2PGxJbHFPSV0LG1HF3l/4QoG1JNYKuyXaPFDtPcefU7fQ7MqpAiBWj2 -HyIfnKyZyUX867Pg2CnSPTgMZyK3NMSnkAG+FSZzXKj6Vxjh0mKN1X+JRqyv1c/r -WLg3RxecuVdq0D23l/1nZPWidiRp2XhUKeO3L3rLKu9tf80HJZ1TN3TsUxOGUgZH -ectN577qAaHr3KzDHVA4v7fmNRXQEUg4Iax8FxWIw0CQOFY9+lUqW1TKr7Umrqnz -mhQmLFNBP5wuU1fIyz5HqNeR2VOt10VDBfYg7Wy08mFsSr3bhj9GMwIDAQABo1Aw -TjAdBgNVHQ4EFgQUdme3OnuvS+09/3Nkx1FS/4xgXoUwHwYDVR0jBBgwFoAUdme3 -OnuvS+09/3Nkx1FS/4xgXoUwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC -AQEANNo5qASaoCc5/Utr10KIO/KoFesWL0v0bDIG3r0HaEp2gD44o884yQ8pFDNR -bhUjs1SaHHcnGX5X3OGJVuP2vuz0pFaeKZJUawT1DTwaXQCTkRFw0Cb7oWdF6/FV -pXuLBXWvEsu19jnrZGQbJw1lsOchPDNpp/h460JBWQOOYRfu3jSyBMwih40lEb+F -1lA943aA2oGsDYJHnGRpUMLafe4gqcGWpfHUf2TaGe+tUSI7+JrhYLzjRyqTBdD3 -5Ss+eQo5tP0aOVMMCMCHmFfxgjBHGu4syQW5VYIZtzWMEKu2fEZCjkbV6XwVYVFL -SKh7EgT1te7yUhfLSAF2969WHg== ------END CERTIFICATE----- diff --git a/traffic_ops/experimental/webfront/README.md b/traffic_ops/experimental/webfront/README.md index 576f86e62..aa73a3e53 100644 --- a/traffic_ops/experimental/webfront/README.md +++ b/traffic_ops/experimental/webfront/README.md @@ -1,52 +1,77 @@ -A reverse proxy written in go that can front any number of microservices. It uses a rules file to map from requested host/path to microservice host/port/path. Example rule file: +A reverse proxy written in go that can front any number of microservices. It uses a rules file to map from requested host/path to microservice host/port/path. +The API GW forwarding logic is as follow: +Find host to forward the request: Prefix match on the request path against a list of forwarding rules. The matched forwarding rule defines the target's host, and the target's authorization rules. +Authorization: Regex match on the request path against a list of authorization rules. The matched rule defines the required capabilities to perform the HTTP method on the route. These capabilities are compared against the user's capabilities in the user's JWT + +Example forward rules file: + +```json +[ + { "host": "localhost", "path": "/login", "forward": "localhost:9004", "scheme": "https", "auth": false }, + { "host": "localhost", "path": "/api/1.2/innovation/", "forward": "localhost:8004", "scheme": "http", "auth": false }, + { "host": "localhost", "path": "/api/1.2/", "forward": "localhost:3000", "scheme": "http", "auth": true, "routes-file": "traffic-ops-routes.json" }, + { "host": "localhost", "path": "/internal/api/1.2/", "forward": "localhost:3000", "scheme": "http", "auth": true, "routes-file": "internal-routes.json" } +] +``` + +Example authorised routes file: ```json - [ - { - "host": "domain.com", - "path": "/login", - "forward": "localhost:9004", - "secure": false - }, - { - "host": "domain.com", - "path": "/ds/", - "forward": "localhost:8081", - "secure": true, - "capabilities": { - "GET": "read-ds", - "POST": "write-ds", - "PUT": "write-ds", - "PATCH": "write-ds" - } - }, - { - "host": "domain.com", - "path": "/cachegroups/", - "forward": "localhost:8082", - "secure": true, - "capabilities": { - "GET": "read-cg", - "POST": "write-cg", - "PUT": "write-cg", - "PATCH": "write-cg" - } - } - ] +[ + { "match": "/cdns/health", "auth": { "GET": ["cdn-health-read"] }}, + { "match": "/cdns/capacity", "auth": { "GET": ["cdn-health-read"] }}, + { "match": "/cdns/usage/overview", "auth": { "GET": ["cdn-stats-read"] }}, + { "match": "/cdns/name/dnsseckeys/generate", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/cdns/name/[^\/]+/?", "auth": { "GET": ["cdn-read"] }}, + { "match": "/cdns/name/[^\/]+/sslkeys", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/cdns/name/[^\/]+/dnsseckeys", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/cdns/name/[^\/]+/dnsseckeys/delete", "auth": { "GET": ["cdn-security-keys-write"] }}, + { "match": "/cdns/[^\/]+/queue_update", "auth": { "POST": ["queue-updates-write"] }}, + { "match": "/cdns/[^\/]+/snapshot", "auth": { "PUT": ["cdn-config-snapshot-write"] }}, + { "match": "/cdns/[^\/]+/health", "auth": { "GET": ["cdn-health-read"] }}, + { "match": "/cdns/[^\/]+/?", "auth": { "GET": ["cdn-read"], "PUT": ["cdn-write"], "PATCH": ["cdn-write"], "DELETE": ["cdn-write"] }}, + { "match": "/cdns", "auth": { "GET": ["cdn-read"], "POST": ["cdn-write"] }} +] ``` -Note: Access "capabilities" are set in the rule file as a workaround, until TO DB tables are ready. +No restart is needed to re-read the forwarding rule file and apply; within 60 seconds of a change in the file, it will pick up the new mappings. +However, authorized routes files are not re-read. Touch the forwarding rule file to trigger an update. + +**Legacy TO support** + +Currently, the Mojo app reqires a valid Mojo token. As long as the Mojo code use a Mojo token for authorization, the Auth server and the API GW handle legacy authorization in the following way + +* Upon every sucessful login, the auth server performs additional login against the Mojo app and recieves a Mojo token +* The Mojo token is added as a claim to the user's JWT +* Upon successive API calls, the API GW pulls the claim from the JWT and set a "mojolicious" cookie on the request -No restart is needed to re-read the rule file and apply; within 60 seconds of a change in the file, it will pick up the new mappings. +In addition, if a request contains a "mojolicious" cookie instead of an authentication bearer token, the API GW bypass JWT authentication. +This is to support legacy code that access TO API without logging in via the new auth server. -* To run: -`go run webfront.go webfront.config my-secret` -`secret` is used for jwt signing +**Before you begin** +You will need to generate a server certificate for ssl connections against webfront. In the project directory, run +~~~~ +openssl req -x509 -sha256 -nodes -days 3650 -newkey rsa:2048 -keyout server.key -out server.crt +~~~~ -* To login: -`curl -X POST --insecure -Lkvs --header "Content-Type:application/json" https://localhost:9004/login -d'{"username":"foo", "password":"bar"}'` + +**Run the server** + + `go run webfront.go webfront.config my-secret` + + `my-secret` is used for jwt signing + + +**Perform a login call (to get a token)** + + `curl -X POST --insecure -Lkvs --header "Content-Type:application/json" https://localhost:9004/login -d'{"username":"foo", "password":"bar"}'` -* To use a token: -`curl --insecure -H'Authorization: Bearer <token>' -Lkvs https://localhost:8080/ds/` +**Perform an API call (using the token +)** + + `curl --insecure -H'Authorization: Bearer <token>' -Lkvs https://localhost:8080/ds/` + +#### Legacy TO support + 1 Since Mojo app requires a valid Mojo token, the auth server performs a legacy Traffic Ops login upon ever diff --git a/traffic_ops/experimental/webfront/internal-routes.json b/traffic_ops/experimental/webfront/internal-routes.json new file mode 100644 index 000000000..412490bc7 --- /dev/null +++ b/traffic_ops/experimental/webfront/internal-routes.json @@ -0,0 +1,8 @@ +[ + { "match": "/cdns/dnsseckeys/refresh", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/current_stats", "auth": { "GET": ["cache-stats-read"] }}, + { "match": "/daily_summary", "auth": { "GET": ["cache-stats-read"] }}, + { "match": "/federations", "auth": { "GET": ["federations-read"] }}, + { "match": "/steering/[^\/]+/?", "auth": { "GET": ["ds-steering-read"], "PUT": ["ds-steering-write"], "PATCH": ["ds-steering-write"], "DELETE": ["ds-steering-write"] }}, + { "match": "/steering", "auth": { "GET": ["ds-steering-read"], "POST": ["ds-steering-write"] }} +] diff --git a/traffic_ops/experimental/webfront/rules.json b/traffic_ops/experimental/webfront/rules.json index 46d9f2801..891d71b3f 100644 --- a/traffic_ops/experimental/webfront/rules.json +++ b/traffic_ops/experimental/webfront/rules.json @@ -1,32 +1,6 @@ [ - { - "host": "domain.com", - "path": "/login", - "forward": "localhost:9004", - "secure": false - }, - { - "host": "domain.com", - "path": "/ds/", - "forward": "localhost:8081", - "secure": true, - "capabilities": { - "GET": "read-ds", - "POST": "write-ds", - "PUT": "write-ds", - "PATCH": "write-ds" - } - }, - { - "host": "domain.com", - "path": "/cachegroups/", - "forward": "localhost:8082", - "secure": true, - "capabilities": { - "GET": "read-cg", - "POST": "write-cg", - "PUT": "write-cg", - "PATCH": "write-cg" - } - } -] \ No newline at end of file + { "host": "localhost", "path": "/auth/", "forward": "localhost:9004", "scheme": "https", "auth": false }, + { "host": "localhost", "path": "/ops/innovation/", "forward": "localhost:8004", "scheme": "http", "auth": false }, + { "host": "localhost", "path": "/ops/internal", "forward": "localhost:3000", "scheme": "http", "auth": true, "routes-file": "internal-routes.json" }, + { "host": "localhost", "path": "/ops/", "forward": "localhost:3000", "scheme": "http", "auth": true, "routes-file": "traffic-ops-routes.json" } +] diff --git a/traffic_ops/experimental/webfront/server.pem b/traffic_ops/experimental/webfront/server.pem deleted file mode 100644 index c09c2ca87..000000000 --- a/traffic_ops/experimental/webfront/server.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDbzCCAlegAwIBAgIJAJozT3F7lmQwMA0GCSqGSIb3DQEBCwUAME4xCzAJBgNV -BAYTAlVTMQswCQYDVQQIDAJDTzEPMA0GA1UEBwwGRGVudmVyMSEwHwYDVQQKDBhJ -bnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYwMzI2MjIyODExWhcNMjYwMzI0 -MjIyODExWjBOMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ08xDzANBgNVBAcMBkRl -bnZlcjEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0g8TSrCJLfzMxzCB+zLo19NIrOAA2kR2 -5vi8Hu+aM2PGxJbHFPSV0LG1HF3l/4QoG1JNYKuyXaPFDtPcefU7fQ7MqpAiBWj2 -HyIfnKyZyUX867Pg2CnSPTgMZyK3NMSnkAG+FSZzXKj6Vxjh0mKN1X+JRqyv1c/r -WLg3RxecuVdq0D23l/1nZPWidiRp2XhUKeO3L3rLKu9tf80HJZ1TN3TsUxOGUgZH -ectN577qAaHr3KzDHVA4v7fmNRXQEUg4Iax8FxWIw0CQOFY9+lUqW1TKr7Umrqnz -mhQmLFNBP5wuU1fIyz5HqNeR2VOt10VDBfYg7Wy08mFsSr3bhj9GMwIDAQABo1Aw -TjAdBgNVHQ4EFgQUdme3OnuvS+09/3Nkx1FS/4xgXoUwHwYDVR0jBBgwFoAUdme3 -OnuvS+09/3Nkx1FS/4xgXoUwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC -AQEANNo5qASaoCc5/Utr10KIO/KoFesWL0v0bDIG3r0HaEp2gD44o884yQ8pFDNR -bhUjs1SaHHcnGX5X3OGJVuP2vuz0pFaeKZJUawT1DTwaXQCTkRFw0Cb7oWdF6/FV -pXuLBXWvEsu19jnrZGQbJw1lsOchPDNpp/h460JBWQOOYRfu3jSyBMwih40lEb+F -1lA943aA2oGsDYJHnGRpUMLafe4gqcGWpfHUf2TaGe+tUSI7+JrhYLzjRyqTBdD3 -5Ss+eQo5tP0aOVMMCMCHmFfxgjBHGu4syQW5VYIZtzWMEKu2fEZCjkbV6XwVYVFL -SKh7EgT1te7yUhfLSAF2969WHg== ------END CERTIFICATE----- diff --git a/traffic_ops/experimental/webfront/traffic-ops-routes.json b/traffic_ops/experimental/webfront/traffic-ops-routes.json new file mode 100644 index 000000000..eb9e437fa --- /dev/null +++ b/traffic_ops/experimental/webfront/traffic-ops-routes.json @@ -0,0 +1,125 @@ +[ + { "match": "/asns/[^\/]+/?", "auth": { "GET": ["asn-read"], "PUT": ["asn-write"], "PATCH": ["asn-write"], "DELETE": ["asn-write"] }}, + { "match": "/asns", "auth": { "GET": ["asn-read"], "POST": ["asn-write"] }}, + + { "match": "/cachegroups/[^\/]+/queue_update", "auth": { "POST": ["queue-updates-write"] }}, + { "match": "/cachegroups/[^\/]+/parameters", "auth": { "GET": ["params-read"] }}, + { "match": "/cachegroups/[^\/]+/?", "auth": { "GET": ["cache-group-read"], "PUT": ["cache-group-write"], "PATCH": ["cache-group-write"], "DELETE": ["cache-group-write"] }}, + { "match": "/cachegroups", "auth": { "GET": ["cache-group-read"], "POST": ["cache-group-write"] }}, + + { "match": "/cachegroupparameters", "auth": { "GET": ["params-read"] }}, + + { "match": "/cdns/health", "auth": { "GET": ["cdn-health-read"] }}, + { "match": "/cdns/capacity", "auth": { "GET": ["cdn-health-read"] }}, + { "match": "/cdns/usage/overview", "auth": { "GET": ["cdn-stats-read"] }}, + { "match": "/cdns/name/dnsseckeys/generate", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/cdns/name/[^\/]+/?", "auth": { "GET": ["cdn-read"] }}, + { "match": "/cdns/name/[^\/]+/sslkeys", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/cdns/name/[^\/]+/dnsseckeys", "auth": { "GET": ["cdn-security-keys-read"] }}, + { "match": "/cdns/name/[^\/]+/dnsseckeys/delete", "auth": { "GET": ["cdn-security-keys-write"] }}, + { "match": "/cdns/[^\/]+/queue_update", "auth": { "POST": ["queue-updates-write"] }}, + { "match": "/cdns/[^\/]+/snapshot", "auth": { "PUT": ["cdn-config-snapshot-write"] }}, + { "match": "/cdns/[^\/]+/health", "auth": { "GET": ["cdn-health-read"] }}, + { "match": "/cdns/[^\/]+/?", "auth": { "GET": ["cdn-read"], "PUT": ["cdn-write"], "PATCH": ["cdn-write"], "DELETE": ["cdn-write"] }}, + { "match": "/cdns", "auth": { "GET": ["cdn-read"], "POST": ["cdn-write"] }}, + + { "match": "/deliveryservices/list", "auth": { "GET": ["ds-read"] }}, + { "match": "/deliveryservices/request", "auth": { "POST": ["ds-write"] }}, + { "match": "/deliveryservices/sslkeys/generate", "auth": { "POST": ["ds-security-keys-write"] }}, + { "match": "/deliveryservices/sslkeys/add", "auth": { "POST": ["ds-security-keys-write"] }}, + { "match": "/deliveryservices/hostname/[^\/]+/sslkeys", "auth": { "GET": ["ds-security-keys-read"] }}, + { "match": "/deliveryservices/xmlId/[^\/]+/sslkeys", "auth": { "GET": ["ds-security-keys-read"] }}, + { "match": "/deliveryservices/xmlId/[^\/]+/sslkeys/delete", "auth": { "GET": ["ds-security-keys-write"] }}, + { "match": "/deliveryservices/xmlId/[^\/]+/urlkeys", "auth": { "GET": ["ds-security-keys-read"] }}, + { "match": "/deliveryservices/xmlId/[^\/]+/urlkeys/generate", "auth": { "POST": ["ds-security-keys-write"] }}, + { "match": "/deliveryservices/[^\/]+/get", "auth": { "GET": ["ds-read"] }}, + { "match": "/deliveryservices/[^\/]+/health", "auth": { "GET": ["ds-health-read"] }}, + { "match": "/deliveryservices/[^\/]+/capacity", "auth": { "GET": ["ds-health-read"] }}, + { "match": "/deliveryservices/[^\/]+/routing", "auth": { "GET": ["ds-read"] }}, + { "match": "/deliveryservices/[^\/]+/state", "auth": { "GET": ["ds-read"] }}, + { "match": "/deliveryservices/[^\/]+/regexes", "auth": { "GET": ["ds-read"] }}, + { "match": "/[^\/]+/deliveryservices/create", "auth": { "POST": ["ds-write"] }}, + { "match": "/[^\/]+/deliveryservices/update", "auth": { "PUT": ["ds-write"] }}, + { "match": "/deliveryservices/[^\/]+/regexes/[^\/]+/?", "auth": { "GET": ["ds-read"], "PUT": ["ds-write"], "PATCH": ["ds-write"], "DELETE": ["ds-write"] }}, + { "match": "/deliveryservices/[^\/]+/regexes", "auth": { "GET": ["ds-read"], "POST": ["ds-write"] }}, + { "match": "/deliveryservices/[^\/]+/?", "auth": { "GET": ["ds-read"], "PUT": ["ds-write"], "PATCH": ["ds-write"], "DELETE": ["ds-write"] }}, + { "match": "/deliveryservices", "auth": { "GET": ["ds-read"], "POST": ["ds-write"] }}, + + { "match": "/divisions/[^\/]+/regions", "auth": { "GET": ["region-read"], "POST": ["region-write"] }}, + { "match": "/divisions/[^\/]+/?", "auth": { "GET": ["division-read"], "PUT": ["division-write"], "PATCH": ["division-write"], "DELETE": ["division-write"] }}, + { "match": "/divisions", "auth": { "GET": ["division-read"], "POST": ["division-write"] }}, + + { "match": "/federations/[^\/]+/?", "auth": { "GET": ["federation-routing-read"], "PUT": ["federation-routing-write"], "PATCH": ["federation-routing-write"], "DELETE": ["federation-routing-write"] }}, + { "match": "/federations", "auth": { "GET": ["federation-routing-read"], "POST": ["federation-routing-write"] }}, + + { "match": "/logs", "auth": { "GET": ["change-log-read"] }}, + { "match": "/logs/newcount", "auth": { "GET": ["change-log-read"] }}, + { "match": "/logs/[^\/]+/days", "auth": { "GET": ["cdn-health-read"] }}, + + { "match": "/parameters/profile", "auth": { "GET": ["params-read"] }}, + { "match": "/parameters/[^\/]+/validate", "auth": { "POST": ["params-write"] }}, + { "match": "/parameters/[^\/]+/?", "auth": { "GET": ["params-read"], "PUT": ["params-write"], "PATCH": ["params-write"], "DELETE": ["params-write"] }}, + { "match": "/parameters", "auth": { "GET": ["params-read"], "POST": ["params-write"] }}, + + { "match": "/profiles/trimmed", "auth": { "GET": ["profile-read"] }}, + { "match": "/profiles/name/[^\/]+/copy", "auth": { "POST": ["profile-write"] }}, + { "match": "/profiles/name/[^\/]+/parameters", "auth": { "GET": ["profile-read"], "POST": ["profile-write"] }}, + { "match": "/profiles/[^\/]+/parameters", "auth": { "GET": ["profile-read"], "POST": ["profile-write"] }}, + { "match": "/profiles/[^\/]+/?", "auth": { "GET": ["profile-read"], "PUT": ["profile-write"], "PATCH": ["profile-write"], "DELETE": ["profile-write"] }}, + { "match": "/profiles", "auth": { "GET": ["profile-read"], "POST": ["profile-write"] }}, + + { "match": "/profileparameters/[^\/]+/[^\/]+/?", "auth": { "DELETE": ["params-write"] }}, + { "match": "/profileparameters", "auth": { "GET": ["params-read"], "POST": ["params-write"] }}, + + { "match": "/phys_locations/trimmed", "auth": { "GET": ["types-read"] }}, + { "match": "/phys_locations/[^\/]+/?", "auth": { "GET": ["types-read"], "PUT": ["types-write"], "PATCH": ["types-write"], "DELETE": ["types-write"] }}, + { "match": "/phys_locations", "auth": { "GET": ["types-read"], "POST": ["types-write"] }}, + + { "match": "/regions/[^\/]+/phys_locations", "auth": { "POST": ["types-write"] }}, + { "match": "/regions/[^\/]+/?", "auth": { "GET": ["region-read"], "PUT": ["region-write"], "PATCH": ["region-write"], "DELETE": ["region-write"] }}, + { "match": "/regions", "auth": { "GET": ["region-read"], "POST": ["region-write"] }}, + + { "match": "/riak/ping", "auth": { "GET": ["cdn-security-keys-write"] }}, + { "match": "/riak/stats", "auth": { "GET": ["security-keys-read"] }}, + { "match": "/riak/bucket/[^\/]+/key/[^\/]+/values", "auth": { "GET": ["security-keys-read"] }}, + + { "match": "/servers/details", "auth": { "GET": ["server-read"] }}, + { "match": "/servers/totals", "auth": { "GET": ["server-read"] }}, + { "match": "/servers/checks", "auth": { "GET": ["server-read"] }}, + { "match": "/servers/[^\/]+/queue_update", "auth": { "POST": ["queue-updates-write"] }}, + { "match": "/servers/[^\/]+/?", "auth": { "GET": ["server-read"], "PUT": ["server-write"], "PATCH": ["server-write"], "DELETE": ["server-write"] }}, + { "match": "/servers", "auth": { "GET": ["server-read"] }}, + + { "match": "/serverscheck", "auth": { "POST": ["server-write"] }}, + { "match": "/serverscheck/aadata", "auth": { "GET": ["server-read"] }}, + + { "match": "/snapshot/[^\/]+/?", "auth": { "PUT": ["cdn-config-snapshot-write"] }}, + + { "match": "/statuses/[^\/]+/?", "auth": { "GET": ["status-read"] }}, + { "match": "/statuses", "auth": { "GET": ["status-read"] }}, + + { "match": "/to_extensions/[^\/]+/delete", "auth": { "POST": ["to-extension-write"] }}, + { "match": "/to_extensions", "auth": { "GET": ["to-extension-read"], "POST": ["to-extension-write"] }}, + + { "match": "/types/trimmed", "auth": { "GET": ["type-read"] }}, + { "match": "/types/[^\/]+/?", "auth": { "GET": ["type-read"], "PUT": ["type-write"], "PATCH": ["type-write"], "DELETE": ["type-write"] }}, + { "match": "/types", "auth": { "GET": ["type-read"], "POST": ["type-write"] }}, + + { "match": "/users/[^\/]+/?", "auth": { "GET": ["user-read"], "PUT": ["user-write"] }}, + { "match": "/users", "auth": { "GET": ["user-read"] }}, + + { "match": "/cache_stats", "auth": { "GET": ["cache-stats-read"] }}, + { "match": "/deliveryservice_matches", "auth": { "GET": ["ds-read"] }}, + { "match": "/deliveryservice_regexes", "auth": { "GET": ["ds-read"] }}, + { "match": "/deliveryservice_stats", "auth": { "GET": ["ds-stats-read"] }}, + { "match": "/deliveryserviceserver", "auth": { "GET": ["ds-cache-read"] }}, + { "match": "/hwinfo", "auth": { "GET": ["all-read"] }}, + { "match": "/keys/ping", "auth": { "GET": ["security-keys-read"] }}, + { "match": "/roles", "auth": { "GET": ["role-read"] }}, + { "match": "/staticdnsentries", "auth": { "GET": ["static-dns-read"] }}, + { "match": "/system/info", "auth": { "GET": ["basic-read"] }}, + + { "match": "/[^\/]+/stats_summary/create", "auth": { "PUT": ["cdn-stats-read"] }}, + { "match": "/[^\/]+/stats_summary", "auth": { "PUT": ["cdn-stats-read"] }} + +] diff --git a/traffic_ops/experimental/webfront/webfront.config b/traffic_ops/experimental/webfront/webfront.config index b54f3df9d..3d190ce84 100644 --- a/traffic_ops/experimental/webfront/webfront.config +++ b/traffic_ops/experimental/webfront/webfront.config @@ -1,5 +1,15 @@ { - "rule-file": "rules.json", - "poll-interval": 60, - "listen-port": 8080 -} \ No newline at end of file + "listen-port": 8080, + "rule-file": "rules.json", + "poll-interval": 5, + "crt-file": "server.crt", + "key-file": "server.key", + "log-dir": ".", + "dbg-log-max-size": 500, + "dbg-log-max-age": 28, + "dbg-log-max-backups": 3, + "access-log-max-size": 500, + "access-log-max-age": 28, + "access-log-max-backups": 3, + "insecure-skip-verify": false +} diff --git a/traffic_ops/experimental/webfront/webfront.go b/traffic_ops/experimental/webfront/webfront.go index 60371a42c..ac8e6f4bb 100644 --- a/traffic_ops/experimental/webfront/webfront.go +++ b/traffic_ops/experimental/webfront/webfront.go @@ -23,56 +23,97 @@ import ( "encoding/json" "fmt" jwt "github.com/dgrijalva/jwt-go" + "github.com/lestrrat/go-apache-logformat" + "gopkg.in/natefinch/lumberjack.v2" + "io" "log" "net/http" "net/http/httputil" "os" "path" + "path/filepath" + "regexp" "strconv" "strings" "sync" "time" ) -// TODO(amiry) - Handle refresh tokens +// TODO(amiry) - Cantralized, managed route configuration +// TODO(amiry) - Auth server: Legacy token expiration should be longer than JWT expiration +// TODO(amiry) - Test regex match performance +// TODO(amiry) - Test/Document: Deprecate API with empty "auth" object in json +// TODO(amiry) - Add "/" route for admin user? This will cause non existant routes to return Forbidden instead of Not Found +// { "match": "/.*", "auth": { "GET": ["all-read"], "POST": ["all-write"], "PUT": ["all-write"], "PATCH": ["all-write"], "DELETE": ["all-write"] }} + + +// Config holds the configuration of the server. +type Config struct { + ListenPort int `json:"listen-port"` + RuleFile string `json:"rule-file"` + PollInterval int `json:"poll-interval"` + CrtFile string `json:"crt-file"` + KeyFile string `json:"key-file"` + LogDir string `json:"log-dir"` + + DbgLogMaxSize int `json:"dbg-log-max-size"` // Megabytes + DbgLogMaxAge int `json:"dbg-log-max-age"` // Days + DbgLogMaxBackups int `json:"dbg-log-max-backups"` + + AccessLogMaxSize int `json:"access-log-max-size"` // Megabytes + AccessLogMaxAge int `json:"access-log-max-age"` // Days + AccessLogMaxBackups int `json:"access-log-max-backups"` + + InsecureSkipVerify bool `json:"insecure-skip-verify"` +} // Server implements an http.Handler that acts as a reverse proxy type Server struct { mu sync.RWMutex // guards the fields below last time.Time - rules []*Rule + Rules []*FwdRule } -// Rule represents a rule in a configuration file. -type Rule struct { - Host string // to match against request Host header - Path string // to match against a path (start) - Forward string // reverse proxy map-to - Secure bool // protect with jwt? - Capabilities map[string]string // map HTTP methods to capabilitues +// FwdRule represents a FwdRule in a configuration file +type FwdRule struct { + Host string // to match against request Host header + Path string // to match against a path (start) + Forward string // reverse proxy map-to + Scheme string // reverse proxy URL scheme (HTTP) + Auth bool // protect with jwt? + RoutesFile string `json:"routes-file"` // path to routes file + + routes []*Route + handler http.Handler +} - handler http.Handler +type Route struct { + Match string // the route's path regex + Auth map[string]([]string) // map a HTTP method to the capabilities that are required + // to perform the method on this route. Methods that are not + // in this list are forbidden. + // A compiled regex for "Match" + matchRegexp *regexp.Regexp } type Claims struct { - Capabilities []string `json:"cap"` + Capabilities []string `json:"cap"` + LegacyCookie string `json:"legacy-cookie"` // LEGACY: The legacy cookie to be set upon request jwt.StandardClaims } -// Config holds the configuration of the server. -type Config struct { - RuleFile string `json:"rule-file"` - PollInterval int `json:"poll-interval"` - ListenPort int `json:"listen-port"` -} - -var Logger *log.Logger +var logger *log.Logger +var accessLogger *lumberjack.Logger +var apacheCombinedLogPlusDuration, _ = apachelog.New(`%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-agent}i" %D`) func printUsage() { exampleConfig := `{ - "listen-port": 9000, + "listen-port": 8080, "rule-file": "rules.json", - "poll-interval": 60 + "poll-interval": 5, + "crt-file": "server.crt", + "key-file": "server.key", + "insecure-skip-verify": false }` fmt.Println("Usage: " + path.Base(os.Args[0]) + " config-file secret") fmt.Println("") @@ -87,11 +128,10 @@ func main() { return } - Logger = log.New(os.Stdout, " ", log.Ldate|log.Ltime|log.Lshortfile) - + // Load config file, err := os.Open(os.Args[1]) if err != nil { - Logger.Println("Error opening config file:", err) + fmt.Printf("Error opening config file %s: %s\n", os.Args[1], err) return } @@ -99,211 +139,391 @@ func main() { config := Config{} err = decoder.Decode(&config) if err != nil { - Logger.Println("Error reading config file:", err) + fmt.Printf("Error reading config file %s: %s\n", os.Args[1], err) return } - s, err := NewServer(config.RuleFile, time.Duration(config.PollInterval)*time.Second) - if err != nil { - Logger.Fatal(err) - } + dbgLogFileName, _ := filepath.Abs(path.Join(config.LogDir, "debug.log")) + accessLogFileName,_ := filepath.Abs(path.Join(config.LogDir, "access.log")) - // override the default so we can use self-signed certs on our microservices - // and use a self-signed cert in this server - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - if _, err := os.Stat("server.pem"); os.IsNotExist(err) { - Logger.Fatal("server.pem file not found") + dbgLog := &lumberjack.Logger{ + Filename: dbgLogFileName, + MaxSize: config., // megabytes + MaxAge: 28, // days + MaxBackups: 3, } - if _, err := os.Stat("server.key"); os.IsNotExist(err) { - Logger.Fatal("server.key file not found") + + accessLog := &lumberjack.Logger{ + Filename: accessLogFileName, + MaxSize: 500, // megabytes + MaxAge: 28, // days + MaxBackups: 3, } - Logger.Printf("Starting webfront on port %d...", config.ListenPort) - Logger.Fatal(http.ListenAndServeTLS(":" + strconv.Itoa(int(config.ListenPort)), "server.pem", "server.key", s)) -} + // Log to roling file and stdout. Point stdout to /dev/null in production + dbgLogDestinations := io.MultiWriter(dbgLog, os.Stdout) + logger = log.New(dbgLogDestinations, "", log.Ldate|log.Ltime|log.Lshortfile) -func validateToken(tokenString string) (*jwt.Token, error) { + logger.Printf("Debug log %s", dbgLogFileName) + logger.Printf("Access log %s", accessLogFileName) - tokenString = strings.Replace(tokenString, "Bearer ", "", 1) - token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) - } - return []byte(os.Args[2]), nil - }) - return token, err + if _, err := os.Stat(config.CrtFile); os.IsNotExist(err) { + logger.Fatalf("%s file not found", config.CrtFile) + } + if _, err := os.Stat(config.KeyFile); os.IsNotExist(err) { + logger.Fatalf("%s file not found", config.KeyFile) + } + + s, err := NewServer(config.RuleFile, time.Duration(config.PollInterval)*time.Second) + if err != nil { + logger.Fatal(err) + } + + http.DefaultTransport.(*http.Transport).TLSClientConfig = makeTLSConfig(&config) + logger.Printf("Starting webfront on port %d...", config.ListenPort) + logger.Fatal(http.ListenAndServeTLS(":" + strconv.Itoa(int(config.ListenPort)), config.CrtFile, config.KeyFile, + apacheCombinedLogPlusDuration.Wrap(s, accessLog))) } -// NewServer constructs a Server that reads rules from file with a period -// specified by poll. +// NewServer constructs a Server that reads Rules from file with a period +// specified by poll func NewServer(file string, poll time.Duration) (*Server, error) { s := new(Server) if err := s.loadRules(file); err != nil { - Logger.Fatal("Error loading rules file: ", err) + logger.Fatal(fmt.Errorf("Load rules failed: %s", err)) } + + // TODO(amiry) - Reload config using NOHUP signal instead of poll for changes go s.refreshRules(file, poll) + return s, nil } -// ServeHTTP matches the Request with a Rule and, if found, serves the -// request with the Rule's handler. If the rule's secure field is true, it will -// only allow access if the request has a valid JWT bearer token. -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func makeTLSConfig(config *Config) *tls.Config { - rule := s.getRule(r) - if rule == nil { - Logger.Printf("%v %v No mapping in rules file!", r.Method, r.URL.RequestURI()) - http.Error(w, "Not found", http.StatusNotFound) - return + s := false + if config.InsecureSkipVerify == true { + logger.Printf("NOTICE: Skip certificate verification") + s = true } + return &tls.Config{InsecureSkipVerify: s} +} - isAuthorized := false +// loadRules tests whether file has been modified since its last invocation +// and, if so, loads the rule set from file. +func (s *Server) loadRules(file string) error { + + fi, err := os.Stat(file) + if err != nil { + return err + } - if rule.Secure { - tokenValid := false - token, err := validateToken(r.Header.Get("Authorization")) + mtime := fi.ModTime() + if !mtime.After(s.last) && s.Rules != nil { + return nil // no change + } - if err == nil { - tokenValid = true - } else { - Logger.Println("Token Error:", err.Error()) + Rules, err := parseRules(file) + if err != nil { + return err + } + + s.mu.Lock() + s.last = mtime + s.Rules = Rules + s.mu.Unlock() + return nil +} + +// refreshRules polls file periodically and refreshes the Server's rule set +// if the file has been modified. +func (s *Server) refreshRules(file string, poll time.Duration) { + for { + if err := s.loadRules(file); err != nil { + logger.Printf("Refresh rules failed: %s", err) } + time.Sleep(poll) + } +} - if !tokenValid { - Logger.Printf("%v %v Valid token required, but none found!", r.Method, r.URL.RequestURI()) - w.WriteHeader(http.StatusForbidden) - return +// parseRules reads rule definitions from file, constructs the rule handlers, +// and returns the resultant rules. +func parseRules(file string) ([]*FwdRule, error) { + + f, err := os.Open(file) + if err != nil { + return nil, err + } + defer f.Close() + + absPath, _ := filepath.Abs(file) + logger.Printf("Loading rules file: %s", absPath) + + var rules []*FwdRule + if err := json.NewDecoder(f).Decode(&rules); err != nil { + return nil, err + } + + for _, r := range rules { + + if r.Auth { + r.routes, err = parseRoutes(r.RoutesFile) + if err != nil { + logger.Printf("Skip rule %s ERROR: %s", r.Path, err) + continue + } } - claims, ok := token.Claims.(*Claims) - if !ok { - Logger.Printf("%v %v Valid token found, but cannot parse claims!", r.Method, r.URL.RequestURI()) - w.WriteHeader(http.StatusForbidden) - return + r.handler, err = makeHandler(r) + if err != nil { + logger.Printf("Skip rule %s ERROR: %s", r.Path, err) + continue } - // Authorization: Check is the list of capabilities in the token's claims contains - // the reqired capability that is listed in the rule - for _, c := range claims.Capabilities { - if c == rule.Capabilities[r.Method] { - isAuthorized = true - break - } - } + // logger.Printf("Loaded rule: %s", r.Path) + } + + return rules, nil +} + +// parseRoutes reads route definitions from file, constructs the route auth handler, +// and returns the resultant routes. +func parseRoutes(file string) ([]*Route, error) { - Logger.Printf("%v %v Valid token. Subject=%v, ExpiresAt=%v, Capabilities=%v, Required=%v, Authorized=%v", - r.Method, r.URL.RequestURI(), claims.Subject, claims.ExpiresAt, claims.Capabilities, - rule.Capabilities[r.Method], isAuthorized) + // If the rule defines a routes file, we load the routes and enforce access. + // Routes than are not present in this file are forbidden. - } else { - isAuthorized = true + // Note that there is currently no mechanism to trigger an update on a change in the route files. + // To trigger an update, one needs to touch rules.json + + cf, err := os.Open(file) + if err != nil { + return nil, err } + defer cf.Close() - if isAuthorized { - if h := rule.handler; h != nil { - h.ServeHTTP(w, r) - return + absPath, _ := filepath.Abs(file) + logger.Printf("Loading routes file: %s", absPath) + + var routes []*Route + if err := json.NewDecoder(cf).Decode(&routes); err != nil { + return nil, err + } + + for _, r := range routes { + + /* + // If the match ends with a slash, it is treated as a prefix. + // If not, it is an exact match + if !strings.EndsWith(r.Match, "/") { + r.Match = r.Match + '$' } + */ + + r.matchRegexp, err = regexp.Compile(r.Match + "$") + if err != nil { + logger.Printf("Skip route %s ERROR: %s", r.Match, err) + continue + } + + // logger.Printf("Loaded route: %s", r.Match) } - http.Error(w, "Not Authorized", http.StatusUnauthorized) + return routes, nil +} + +// makeHandler constructs the appropriate Handler for the given FwdRule. +func makeHandler(r *FwdRule) (http.Handler, error) { + + host := r.Forward + pathPrefix := "/" + + if i := strings.Index(r.Forward, "/"); i >= 0 { + host = r.Forward[:i] + pathPrefix = r.Forward[i:] + } + + if host == "" { + return nil, fmt.Errorf("Not a forward rule") + } + + return &httputil.ReverseProxy { + Director: func(req *http.Request) { + req.URL.Scheme = r.Scheme + req.URL.Host = host + req.URL.Path = pathPrefix + strings.TrimPrefix(req.URL.Path, r.Path) + // logger.Printf("Proxy: HOST: %s PATH: %s", req.URL.Host, req.URL.Path) + }, + }, nil +} + +///////////////////////////////////////////////////////////////////////////////////////////////////// + +// ServeHTTP matches the Request with a forward rule and, if found, serves the +// request with the rule's handler. If the rule's secure field is true, it will +// only allow access if the request has a valid JWT bearer token. +func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + + rule := s.matchRule(req) + if rule == nil { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + if rule.Auth { + authorized := rule.authorize(w, req) + if !authorized { + return + } + } + + if h := rule.handler; h != nil { + h.ServeHTTP(w, req) + return + } + + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } -func rejectNoToken(handler http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) +func (rule *FwdRule) authorize(w http.ResponseWriter, req *http.Request) bool { + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // LEGACY: If request contains a Mojo cookie instead of a JWT, we bypass token authorization + // and let legacy TO handle all authorization. + var cookie, err = req.Cookie("mojolicious") + if cookie != nil { + logger.Printf("LEGACY: Found mojolicious cookie. Bypass authorization") + return true + } + ///////////////////////////////////////////////////////////////////////////////////////////////// + + token, err := validateToken(req.Header.Get("Authorization")) + + if err != nil { + logger.Printf("%v %v Token error: %s", req.Method, req.URL.RequestURI(), err) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return false + } + + claims, ok := token.Claims.(*Claims) + if !ok { + logger.Printf("%v %v Token valid but cannot parse claims", req.Method, req.URL.RequestURI()) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return false + } + + route := rule.matchRoute(req) + if route == nil { + logger.Printf("%v %v Route not found", req.Method, req.URL.RequestURI()) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return false } + + method := route.Auth[req.Method] + if method == nil { + logger.Printf("%v %v Route found but method forbidden", req.Method, req.URL.RequestURI()) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return false + } + + // method is actually a list of capabilities required to perform this method. + // Re performance - the lists are VERY short + satisfied := len(method) + for _, need := range method { + for _, has := range claims.Capabilities { + if has == need { + satisfied-- + } + } + } + + if (satisfied > 0) { + logger.Printf("%v %v Route found but required capabilities not satisfied. HAS %v, NEED %v", + req.Method, req.URL.RequestURI(), claims.Capabilities, method) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return false + } + + logger.Printf("%v %v Authorized. Subject=%v, ExpiresAt=%v, Rule=%s, Route=%s, Has=%v, Need=%v", + req.Method, req.URL.RequestURI(), claims.Subject, claims.ExpiresAt, rule.Path, route.Match, method, claims.Capabilities) + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // LEGACY: Pass legacy authentication token upon every secured request... + legacyCookie := claims.LegacyCookie; + req.Header.Add("Cookie", legacyCookie) + ///////////////////////////////////////////////////////////////////////////////////////////////// + + return true +} + +func validateToken(tokenString string) (*jwt.Token, error) { + + tokenString = strings.Replace(tokenString, "Bearer ", "", 1) + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + return []byte(os.Args[2]), nil + }) + return token, err } -func (s *Server) getRule(req *http.Request) *Rule { +func (s *Server) matchRule(req *http.Request) *FwdRule { + s.mu.RLock() defer s.mu.RUnlock() - h := req.Host + // h := req.Host p := req.URL.Path + /* // Some clients include a port in the request host; strip it. if i := strings.Index(h, ":"); i >= 0 { h = h[:i] } + */ + + for _, r := range s.Rules { - for _, r := range s.rules { + // Rules are matched in order! Longer rules should take precedence in rule file. + // logger.Printf("CHECK RULE: PATH %s BEGINS WITH %s ?", p, r.Path) if strings.HasPrefix(p, r.Path) { - // Logger.Printf("Found rule") + // logger.Printf("FOUND RULE: %s", r.Path) return r } } - // Logger.Printf("Rule not found") + // logger.Printf("Rule not found for path: %s", p) return nil } -// refreshRules polls file periodically and refreshes the Server's rule -// set if the file has been modified. -func (s *Server) refreshRules(file string, poll time.Duration) { - for { - // Logger.Printf("loading rule file") - if err := s.loadRules(file); err != nil { - Logger.Println(file, ":", err) - } - time.Sleep(poll) - } -} +func (r *FwdRule) matchRoute(req *http.Request) *Route { -// loadRules tests whether file has been modified since its last invocation -// and, if so, loads the rule set from file. -func (s *Server) loadRules(file string) error { - fi, err := os.Stat(file) - if err != nil { - return err - } - mtime := fi.ModTime() - if !mtime.After(s.last) && s.rules != nil { - return nil // no change - } - rules, err := parseRules(file) - if err != nil { - return err - } - s.mu.Lock() - s.last = mtime - s.rules = rules - s.mu.Unlock() - return nil -} + // TODO(amiry) - Naive implementation -// parseRules reads rule definitions from file, constructs the Rule handlers, -// and returns the resultant Rules. -func parseRules(file string) ([]*Rule, error) { - f, err := os.Open(file) - if err != nil { - return nil, err - } - defer f.Close() - var rules []*Rule - if err := json.NewDecoder(f).Decode(&rules); err != nil { - return nil, err - } - for _, r := range rules { - r.handler = makeHandler(r) - if r.handler == nil { - Logger.Printf("Bad rule: %#v", r) - } + logger.Printf("MATCH ROUTE: PATH %s", req.URL.Path) + + // h := req.Host + p := req.URL.Path + + /* + // Some clients include a port in the request host; strip it. + if i := strings.Index(h, ":"); i >= 0 { + h = h[:i] } - return rules, nil -} + */ + + for _, r := range r.routes { -// makeHandler constructs the appropriate Handler for the given Rule. -func makeHandler(r *Rule) http.Handler { - if h := r.Forward; h != "" { - return &httputil.ReverseProxy{ - Director: func(req *http.Request) { - req.URL.Scheme = "https" - req.URL.Host = h - // req.URL.Path = "/boo1" // TODO JvD - regex to change path here - }, + // Routes are matched in order! Longer routes should take precedence in rule file. + // logger.Printf("CHECK ROUTE: PATH %s MATCHES %s ?", p, r.Match) + if r.matchRegexp.MatchString(p) { + // logger.Printf("FOUND ROUTE: %s", r.matchRegexp) + return r } } + + // logger.Printf("Route not found for path: %s", p) return nil -} \ No newline at end of file +} ---------------------------------------------------------------- This is an automated message from the Apache Git Service. To respond to the message, please log on GitHub and use the URL above to go to the specific comment. For queries about this service, please contact Infrastructure at: [email protected] With regards, Apache Git Services
