blob: 4fbd76693c896bd0e911659911013b4da75f1bed [file] [log] [blame]
package login
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import (
"bytes"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"path/filepath"
"time"
"github.com/apache/trafficcontrol/lib/go-log"
"github.com/apache/trafficcontrol/lib/go-rfc"
"github.com/apache/trafficcontrol/lib/go-tc"
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/config"
"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/tocookie"
"github.com/dgrijalva/jwt-go"
"github.com/jmoiron/sqlx"
"github.com/lestrrat-go/jwx/jwk"
)
type emailFormatter struct {
From rfc.EmailAddress
To rfc.EmailAddress
InstanceName string
ResetURL string
Token string
}
const instanceNameQuery = `
SELECT value
FROM parameter
WHERE name='tm.instance_name' AND
config_file='global'
`
const userQueryByEmail = `SELECT EXISTS(SELECT * FROM tm_user WHERE email=$1)`
const setTokenQuery = `UPDATE tm_user SET token=$1 WHERE email=$2`
var resetPasswordEmailTemplate = template.Must(template.New("Password Reset Email").Parse("From: {{.From.Address.Address}}\r" + `
To: {{.To.Address.Address}}` + "\r" + `
Content-Type: text/html` + "\r" + `
Subject: {{.InstanceName}} Password Reset Request` + "\r\n\r" + `
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.InstanceName}} Password Reset Request</title>
<meta charset="utf-8"/>
<style>
.button_link {
display: block;
width: 130px;
height: 35px;
background: #2682AF;
padding: 5px;
text-align: center;
border-radius: 5px;
color: white;
font-weight: bold;
text-decoration: none;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<p>Someone has requested to change your password for the {{.InstanceName}}. If you requested this change, please click the link below and change your password. Otherwise, you can disregard this email.</p>
<p><a class="button_link" target="_blank" href="{{.ResetURL}}?token={{.Token}}">Click to Reset Your Password</a></p>
</main>
<footer>
<p>Thank you,<br/>
The {{.InstanceName}} Team</p>
</footer>
</body>
</html>
`))
func LoginHandler(db *sqlx.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
handleErrs := tc.GetHandleErrorsFunc(w, r)
defer r.Body.Close()
authenticated := false
form := auth.PasswordForm{}
if err := json.NewDecoder(r.Body).Decode(&form); err != nil {
handleErrs(http.StatusBadRequest, err)
return
}
if form.Username == "" || form.Password == "" {
api.HandleErr(w, r, nil, http.StatusBadRequest, errors.New("username and password are required"), nil)
return
}
resp := struct {
tc.Alerts
}{}
userAllowed, err, blockingErr := auth.CheckLocalUserIsAllowed(form, db, time.Duration(cfg.DBQueryTimeoutSeconds)*time.Second)
if blockingErr != nil {
api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s\n", blockingErr.Error()))
return
}
if err != nil {
log.Errorf("checking local user: %s\n", err.Error())
}
if userAllowed {
authenticated, err, blockingErr = auth.CheckLocalUserPassword(form, db, time.Duration(cfg.DBQueryTimeoutSeconds)*time.Second)
if blockingErr != nil {
api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s\n", blockingErr.Error()))
return
}
if err != nil {
log.Errorf("checking local user password: %s\n", err.Error())
}
var ldapErr error
if !authenticated {
if cfg.LDAPEnabled {
authenticated, ldapErr = auth.CheckLDAPUser(form, cfg.ConfigLDAP)
if ldapErr != nil {
log.Errorf("checking ldap user: %s\n", ldapErr.Error())
}
}
}
if authenticated {
expiry := time.Now().Add(time.Hour * 6)
cookie := tocookie.New(form.Username, expiry, cfg.Secrets[0])
httpCookie := http.Cookie{Name: "mojolicious", Value: cookie, Path: "/", Expires: expiry, HttpOnly: true}
http.SetCookie(w, &httpCookie)
resp = struct {
tc.Alerts
}{tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in.")}
} else {
resp = struct {
tc.Alerts
}{tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.")}
}
} else {
resp = struct {
tc.Alerts
}{tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.")}
}
respBts, err := json.Marshal(resp)
if err != nil {
handleErrs(http.StatusInternalServerError, err)
return
}
w.Header().Set(tc.ContentType, tc.ApplicationJson)
if !authenticated {
w.WriteHeader(http.StatusUnauthorized)
}
fmt.Fprintf(w, "%s", respBts)
}
}
func TokenLoginHandler(db *sqlx.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var t tc.UserToken
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
api.HandleErr(w, r, nil, http.StatusBadRequest, fmt.Errorf("Invalid request: %v", err), nil)
return
}
tokenMatches, username, err := auth.CheckLocalUserToken(t.Token, db, time.Duration(cfg.DBQueryTimeoutSeconds)*time.Second)
if err != nil {
sysErr := fmt.Errorf("Checking token: %v", err)
errCode := http.StatusInternalServerError
api.HandleErr(w, r, nil, errCode, nil, sysErr)
return
} else if !tokenMatches {
userErr := errors.New("Invalid token. Please contact your administrator.")
errCode := http.StatusUnauthorized
api.HandleErr(w, r, nil, errCode, userErr, nil)
return
}
expiry := time.Now().Add(time.Hour * 6)
cookie := tocookie.New(username, expiry, cfg.Secrets[0])
httpCookie := http.Cookie{Name: "mojolicious", Value: cookie, Path: "/", Expires: expiry, HttpOnly: true}
http.SetCookie(w, &httpCookie)
respBts, err := json.Marshal(tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in."))
if err != nil {
sysErr := fmt.Errorf("Marshaling response: %v", err)
errCode := http.StatusInternalServerError
api.HandleErr(w, r, nil, errCode, nil, sysErr)
return
}
w.Header().Set(tc.ContentType, tc.ApplicationJson)
w.Write(append(respBts, '\n'))
// TODO: afaik, Perl never clears these tokens. They should be reset to NULL on login, I think.
}
}
// OauthLoginHandler accepts a JSON web token previously obtained from an OAuth provider, decodes it, validates it, authorizes the user against the database, and returns the login result as either an error or success message
func OauthLoginHandler(db *sqlx.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
handleErrs := tc.GetHandleErrorsFunc(w, r)
defer r.Body.Close()
authenticated := false
resp := struct {
tc.Alerts
}{}
form := auth.PasswordForm{}
parameters := struct {
AuthCodeTokenUrl string `json:"authCodeTokenUrl"`
Code string `json:"code"`
ClientId string `json:"clientId"`
RedirectUri string `json:"redirectUri"`
}{}
if err := json.NewDecoder(r.Body).Decode(&parameters); err != nil {
handleErrs(http.StatusBadRequest, err)
return
}
data := url.Values{}
data.Add("code", parameters.Code)
data.Add("client_id", parameters.ClientId)
data.Add("client_secret", cfg.ConfigTrafficOpsGolang.OAuthClientSecret)
data.Add("grant_type", "authorization_code") // Required by RFC6749 section 4.1.3
data.Add("redirect_uri", parameters.RedirectUri)
req, err := http.NewRequest(http.MethodPost, parameters.AuthCodeTokenUrl, bytes.NewBufferString(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if err != nil {
log.Errorf("obtaining token using code from oauth provider: %s", err.Error())
return
}
client := http.Client{
Timeout: 30 * time.Second,
}
response, err := client.Do(req)
if err != nil {
log.Errorf("getting an http client: %s", err.Error())
return
}
defer response.Body.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(response.Body)
encodedToken := ""
var result map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
log.Warnf("Error parsing JSON response from oAuth: %s", err.Error())
encodedToken = buf.String()
} else if _, ok := result["access_token"]; !ok {
sysErr := fmt.Errorf("Missing access token in response: %s\n", buf.String())
usrErr := errors.New("Bad response from OAuth2.0 provider")
api.HandleErr(w, r, nil, http.StatusBadGateway, usrErr, sysErr)
return
} else {
switch t := result["access_token"].(type) {
case string:
encodedToken = result["access_token"].(string)
default:
sysErr := fmt.Errorf("Incorrect type of access_token! Expected 'string', got '%v'\n", t)
usrErr := errors.New("Bad response from OAuth2.0 provider")
api.HandleErr(w, r, nil, http.StatusBadGateway, usrErr, sysErr)
return
}
}
if encodedToken == "" {
log.Errorf("Token not found in request but is required")
handleErrs(http.StatusBadRequest, errors.New("Token not found in request but is required"))
return
}
decodedToken, err := jwt.Parse(encodedToken, func(unverifiedToken *jwt.Token) (interface{}, error) {
publicKeyUrl := unverifiedToken.Header["jku"].(string)
publicKeyId := unverifiedToken.Header["kid"].(string)
matched, err := VerifyUrlOnWhiteList(publicKeyUrl, cfg.ConfigTrafficOpsGolang.WhitelistedOAuthUrls)
if err != nil {
return nil, err
}
if !matched {
return nil, errors.New("Key URL from token is not included in the whitelisted urls. Received: " + publicKeyUrl)
}
keys, err := jwk.FetchHTTP(publicKeyUrl)
if err != nil {
return nil, errors.New("Error fetching JSON key set with message: " + err.Error())
}
keyById := keys.LookupKeyID(publicKeyId)
if len(keyById) == 0 {
return nil, errors.New("No public key found for id: " + publicKeyId + " at url: " + publicKeyUrl)
}
selectedKey, err := keyById[0].Materialize()
if err != nil {
return nil, errors.New("Error materializing key from JSON key set with message: " + err.Error())
}
return selectedKey, nil
})
if err != nil {
handleErrs(http.StatusInternalServerError, errors.New("Error decoding token with message: "+err.Error()))
log.Errorf("Error decoding token: %s\n", err.Error())
return
}
authenticated = decodedToken.Valid
userId := decodedToken.Claims.(jwt.MapClaims)["sub"].(string)
form.Username = userId
userAllowed, err, blockingErr := auth.CheckLocalUserIsAllowed(form, db, time.Duration(cfg.DBQueryTimeoutSeconds)*time.Second)
if blockingErr != nil {
api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s\n", blockingErr.Error()))
return
}
if err != nil {
log.Errorf("checking local user: %s\n", err.Error())
}
if userAllowed && authenticated {
expiry := time.Now().Add(time.Hour * 6)
cookie := tocookie.New(userId, expiry, cfg.Secrets[0])
httpCookie := http.Cookie{Name: "mojolicious", Value: cookie, Path: "/", Expires: expiry, HttpOnly: true}
http.SetCookie(w, &httpCookie)
resp = struct {
tc.Alerts
}{tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in.")}
} else {
resp = struct {
tc.Alerts
}{tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.")}
}
respBts, err := json.Marshal(resp)
if err != nil {
handleErrs(http.StatusInternalServerError, err)
return
}
w.Header().Set(tc.ContentType, tc.ApplicationJson)
if !authenticated {
w.WriteHeader(http.StatusUnauthorized)
}
if !userAllowed {
w.WriteHeader(http.StatusForbidden)
}
fmt.Fprintf(w, "%s", respBts)
}
}
func VerifyUrlOnWhiteList(urlString string, whiteListedUrls []string) (bool, error) {
for _, listing := range whiteListedUrls {
if listing == "" {
continue
}
urlParsed, err := url.Parse(urlString)
if err != nil {
return false, err
}
matched, err := filepath.Match(listing, urlParsed.Hostname())
if err != nil {
return false, err
}
if matched {
return true, nil
}
}
return false, nil
}
func setToken(addr rfc.EmailAddress, tx *sql.Tx) (string, error) {
t := make([]byte, 16)
_, err := rand.Read(t)
if err != nil {
return "", err
}
t[6] = (t[6] & 0x0f) | 0x40
t[8] = (t[8] & 0x3f) | 0x80
token := fmt.Sprintf("%x-%x-%x-%x-%x", t[0:4], t[4:6], t[6:8], t[8:10], t[10:])
if _, err = tx.Exec(setTokenQuery, token, addr.Address.Address); err != nil {
return "", err
}
return string(token), nil
}
func createMsg(addr rfc.EmailAddress, t string, db *sqlx.DB, c config.ConfigPortal) ([]byte, error) {
var instanceName string
row := db.QueryRow(instanceNameQuery)
if err := row.Scan(&instanceName); err != nil {
return nil, err
}
f := emailFormatter{
From: c.EmailFrom,
To: addr,
Token: t,
InstanceName: instanceName,
ResetURL: c.BaseURL.String() + c.PasswdResetPath,
}
var tmpl bytes.Buffer
if err := resetPasswordEmailTemplate.Execute(&tmpl, &f); err != nil {
return nil, err
}
return tmpl.Bytes(), nil
}
func ResetPassword(db *sqlx.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var userErr, sysErr error
var errCode int
tx, err := db.Begin()
if err != nil {
sysErr = fmt.Errorf("Beginning transaction: %v", err)
errCode = http.StatusInternalServerError
api.HandleErr(w, r, tx, errCode, nil, sysErr)
return
}
defer r.Body.Close()
var req tc.UserPasswordResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
userErr = fmt.Errorf("Malformed request: %v", err)
errCode = http.StatusBadRequest
api.HandleErr(w, r, tx, errCode, userErr, nil)
return
}
row := tx.QueryRow(userQueryByEmail, req.Email.Address.Address)
var userExists bool
if err := row.Scan(&userExists); err != nil {
sysErr = fmt.Errorf("Checking for existence of user with email '%s': %v", req.Email.String(), err)
errCode = http.StatusInternalServerError
api.HandleErr(w, r, tx, errCode, nil, sysErr)
return
}
if !userExists {
// TODO: consider concealing database state from unauthenticated parties;
// this should maybe just return a 2XX w/ success message at this point?
userErr = fmt.Errorf("No account with the email address '%s' was found!", req.Email.Address.Address)
errCode = http.StatusNotFound
api.HandleErr(w, r, tx, errCode, userErr, nil)
return
}
token, err := setToken(req.Email, tx)
if err != nil {
sysErr = fmt.Errorf("Failed to generate and insert UUID: %v", err)
errCode = http.StatusInternalServerError
api.HandleErr(w, r, tx, errCode, nil, sysErr)
return
}
tx.Commit()
msg, err := createMsg(req.Email, token, db, cfg.ConfigPortal)
if err != nil {
sysErr = fmt.Errorf("Failed to create email message: %v", err)
errCode = http.StatusInternalServerError
api.HandleErr(w, r, nil, errCode, nil, sysErr)
return
}
log.Debugf("Sending password reset email to %s", req.Email)
if errCode, userErr, sysErr = api.SendMail(req.Email, msg, &cfg); userErr != nil || sysErr != nil {
api.HandleErr(w, r, nil, errCode, userErr, sysErr)
return
}
alerts := tc.CreateAlerts(tc.SuccessLevel, "Password reset email sent")
respBts, err := json.Marshal(alerts)
if err != nil {
userErr = errors.New("Email was sent, but an error occurred afterward")
sysErr = fmt.Errorf("Marshaling response: %v", err)
errCode = http.StatusInternalServerError
api.HandleErr(w, r, nil, errCode, userErr, sysErr)
return
}
w.Header().Set(tc.ContentType, tc.ApplicationJson)
w.Write(append(respBts, '\n'))
}
}