1
0
mirror of https://github.com/Luzifer/vault-otp-ui.git synced 2024-09-16 15:48:32 +00:00
vault-otp-ui/main.go
Knut Ahlers 3652fda759
Add proper support for shorter period codes
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2019-09-14 14:33:03 +02:00

263 lines
7.6 KiB
Go

package main
//go:generate go-bindata -pkg $GOPACKAGE -o assets.go index.html application.js static/...
import (
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
log "github.com/sirupsen/logrus"
"github.com/tdewolff/minify"
"github.com/tdewolff/minify/html"
"github.com/tdewolff/minify/js"
validator "gopkg.in/validator.v2"
"github.com/Luzifer/rconfig/v2"
)
const (
requiredScope = "read:org,user"
sessionName = "vault-otp-ui"
)
var (
cfg struct {
Github struct {
ClientID string `flag:"client-id" default:"" env:"CLIENT_ID" description:"Github oAuth2 application Client ID" validate:"nonzero"`
ClientSecret string `flag:"client-secret" default:"" env:"CLIENT_SECRET" description:"Github oAuth2 application Client Secret" validate:"nonzero"`
}
Listen string `flag:"listen" default:":3000" description:"IP/Port to listen on"`
LogLevel string `flag:"log-level" default:"info" description:"Set log level (debug, info, warning, error)"`
SessionSecret string `flag:"session-secret" default:"" env:"SESSION_SECRET" description:"Secret to encrypt the session with"`
Vault struct {
Address string `flag:"vault-addr" env:"VAULT_ADDR" default:"https://127.0.0.1:8200" description:"Vault API address"`
Prefix string `flag:"vault-prefix" env:"VAULT_PREFIX" default:"/totp" description:"Prefix to search for OTP secrets / tokens in"`
SecretField string `flag:"vault-secret-field" env:"VAULT_SECRET_FIELD" default:"secret" description:"Field to search the secret in"`
}
VersionAndExit bool `flag:"version" default:"false" description:"Print version information and exit"`
}
version = "dev"
mini = minify.New()
cookieStore *sessions.CookieStore
)
func loadConfig() error {
if err := rconfig.Parse(&cfg); err != nil {
return err
}
if err := validator.Validate(cfg); err != nil {
return err
}
if l, err := log.ParseLevel(cfg.LogLevel); err == nil {
log.SetLevel(l)
} else {
log.Fatalf("Invalid log level: %s", err)
}
if cfg.VersionAndExit {
fmt.Printf("vault-otp-ui %s\n", version)
os.Exit(0)
}
if cfg.SessionSecret == "" {
cookieStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64), securecookie.GenerateRandomKey(32))
} else {
cookieStore = sessions.NewCookieStore([]byte(cfg.SessionSecret), []byte(fmt.Sprintf("%x", sha1.Sum([]byte(cfg.SessionSecret)))[0:32]))
}
mini.AddFunc("text/html", html.Minify)
mini.AddFunc("application/javascript", js.Minify)
return nil
}
func main() {
var err error
if err = loadConfig(); err != nil {
log.Fatalf("Unable to parse CLI parameters: %s", err)
}
r := mux.NewRouter()
r.HandleFunc("/oauth2", handleOAuthCallback)
r.HandleFunc("/application.js", handleApplicationJS)
r.HandleFunc("/vars.js", handleApplicationVars)
r.HandleFunc("/codes.json", handleCodesJSON)
r.PathPrefix("/static").HandlerFunc(handleStatics)
r.HandleFunc("/", handleIndexPage)
log.Fatalf("HTTP server exitted: %s", http.ListenAndServe(cfg.Listen, r))
}
func getFileContentFallback(filename string) (io.Reader, error) {
if f, err := os.Open(filename); err == nil {
defer f.Close()
buf := new(bytes.Buffer)
io.Copy(buf, f)
return buf, nil
}
if b, err := Asset(filename); err == nil {
return bytes.NewReader(b), nil
}
return nil, errors.New("No suitable index page found")
}
func handleIndexPage(res http.ResponseWriter, r *http.Request) {
content, err := getFileContentFallback("index.html")
if err != nil {
http.Error(res, "No suitable index page found", http.StatusInternalServerError)
}
mini.Minify("text/html", res, content)
}
func handleApplicationJS(res http.ResponseWriter, r *http.Request) {
content, err := getFileContentFallback("application.js")
if err != nil {
http.Error(res, "No suitable file found", http.StatusInternalServerError)
}
mini.Minify("application/javascript", res, content)
}
func handleApplicationVars(w http.ResponseWriter, r *http.Request) {
sess, _ := cookieStore.Get(r, sessionName)
_, hasAccessToken := sess.Values["access_token"]
var buf = new(bytes.Buffer)
fmt.Fprintf(buf, "const signedIn = %v\n", hasAccessToken)
fmt.Fprintf(buf, "const authUrl = %q\n", getAuthenticationURL())
mini.Minify("application/javascript", w, buf)
}
func handleOAuthCallback(res http.ResponseWriter, r *http.Request) {
sess, _ := cookieStore.Get(r, sessionName)
accessToken, err := getAccessToken(r.URL.Query().Get("code"))
if err != nil {
log.Errorf("An error occurred while fetching the access token: %s", err)
http.Error(res, "Something went wrong when fetching your access token. Sorry.", http.StatusInternalServerError)
return
}
if accessToken == "" {
log.Errorf("Code %q was not resolved to an access token", r.URL.Query().Get("code"))
http.Error(res, "Something went wrong when fetching your access token. Sorry.", http.StatusInternalServerError)
return
}
sess.Values["access_token"] = accessToken
if err := sess.Save(r, res); err != nil {
log.Errorf("Was not able to set the cookie: %s", err)
http.Error(res, "Something went wrong when fetching your access token. Sorry.", http.StatusInternalServerError)
return
}
http.Redirect(res, r, "/", http.StatusFound)
}
func handleCodesJSON(res http.ResponseWriter, r *http.Request) {
sess, _ := cookieStore.Get(r, sessionName)
iAccessToken, hasAccessToken := sess.Values["access_token"]
iToken := sess.Values["vault_token"]
if !hasAccessToken {
http.Error(res, `{"error":"Not logged in"}`, http.StatusUnauthorized)
return
}
accessToken := iAccessToken.(string)
var tok string
if iToken != nil {
tok = iToken.(string)
log.WithFields(log.Fields{"token": hashSecret(tok)}).Debugf("Restored token from Session")
}
tok, err := useOrRenewToken(tok, accessToken)
if err != nil {
log.Errorf("Unable to authorize against vault: %s", err)
http.Error(res, `{"error":"Unexpected error while fetching tokens"}`, http.StatusInternalServerError)
return
}
log.WithFields(log.Fields{"token": hashSecret(tok)}).Debugf("Checked / renewed token")
nextTokens := r.URL.Query().Get("it") == "next"
tokens, err := getSecretsFromVault(tok, nextTokens)
if err != nil {
log.Errorf("Unable to fetch codes: %s", err)
http.Error(res, `{"error":"Unexpected error while fetching tokens"}`, http.StatusInternalServerError)
return
}
sess.Values["vault_token"] = tok
if err := sess.Save(r, res); err != nil {
log.Errorf("Was not able to set the cookie: %s", err)
http.Error(res, "Something went wrong while fetching token. Sorry.", http.StatusInternalServerError)
return
}
var (
minPeriod = tokenList(tokens).MinPeriod()
pointOfTime = time.Now()
)
if nextTokens {
pointOfTime = pointOfTime.Add(time.Duration(minPeriod) * time.Second)
}
result := struct {
Tokens []*token `json:"tokens"`
NextWrap time.Time `json:"next_wrap"`
}{
Tokens: tokens,
NextWrap: pointOfTime.Add(time.Duration(minPeriod-(pointOfTime.Second()%minPeriod)) * time.Second),
}
res.Header().Set("Content-Type", "application/json")
res.Header().Set("Cache-Control", "no-cache")
json.NewEncoder(res).Encode(result)
}
func handleStatics(res http.ResponseWriter, r *http.Request) {
req := strings.TrimLeft(r.URL.Path, "/")
ext := req[strings.LastIndex(req, "."):]
t := mime.TypeByExtension(ext)
b, err := Asset(req)
if err != nil {
log.WithFields(log.Fields{
"file": req,
}).Errorf("Static file not found")
http.Error(res, "I don't have that.", http.StatusNotFound)
return
}
res.Header().Set("Content-Type", t)
res.Write(b)
}
func hashSecret(in string) string {
return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(in)))
}