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

Reply via email to