From 26834cf9382358f8affb91616afe6873ec961cc4 Mon Sep 17 00:00:00 2001 From: UnicodingUnicorn <7555ic@gmail.com> Date: Sun, 24 Mar 2019 08:22:22 +0800 Subject: [PATCH] Rewrite in Go with querystring support --- .gitignore | 16 +++++-- Cargo.toml | 16 ------- Dockerfile | 45 +++++--------------- README.md | 2 +- go.mod | 7 ++++ go.sum | 6 +++ main.go | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 102 --------------------------------------------- 8 files changed, 153 insertions(+), 158 deletions(-) delete mode 100644 Cargo.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go delete mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index a0bc7f6..f2dd955 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ -# ---> Rust -/target -Cargo.lock -**/*.rs.bk +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 1339e02..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "backend-auth" -version = "0.1.0" -authors = ["UnicodingUnicorn <7555ic@gmail.com>"] -edition = "2018" - -[dependencies] -dotenv = "0.13.0" -lazy_static = "1.2.0" -iron = "0.6.0" -router = "0.6.0" -jsonwebtoken = "5" -serde = { version = "1.0", features = [ "derive" ] } -serde_json = "1.0" -serde_qs = "0.4.5" -urlencoded = "0.6.0" diff --git a/Dockerfile b/Dockerfile index 7b8ff71..f18ec22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,40 +1,15 @@ -# FROM rust:1.32 as build -FROM alpine:3.9 AS build +FROM golang:1.11-rc-alpine as build -RUN apk add --no-cache gcc musl-dev -RUN apk add --no-cache rust cargo +RUN apk add --no-cache git=2.18.1-r0 -# RUN rustup target add x86_64-unknown-linux-musl +WORKDIR /src +COPY go.mod go.sum .env *.go ./ +RUN go get -d -v ./... +RUN CGO_ENABLED=0 go build -ldflags "-s -w" -# Create new empty shell project -RUN USER=root cargo new --bin app -WORKDIR /app +FROM scratch -# Copy over Cargo.toml -COPY ./Cargo.toml ./Cargo.toml +COPY --from=build /src/auth /auth +COPY --from=build /src/.env /.env -# Change target env -ENV RUSTFLAGS="-C target-cpu=native" -# ENV RUSTFLAGS="-C target-cpu=x86_64_alpine-linux-musl" -# Run build step to cache dependencies -RUN cargo build --release -RUN rm src/*.rs - -# Copy over src files -COPY ./src/main.rs ./src/main.rs - -# Build for release -RUN rm ./target/release/deps/backend_auth* -RUN cargo build --release - -# Copy over .env -COPY ./.env ./.env - -FROM alpine:3.9 - -RUN apk add --no-cache gcc - -COPY --from=build /app/target/release . -COPY --from=build /app/.env .env - -ENTRYPOINT ["./backend-auth"] +ENTRYPOINT ["/auth"] diff --git a/README.md b/README.md index 5737f02..942d630 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # backend-auth -Beep backend auth proxy. At long last, something done properly in Rust. My ancestors are smiling at me, Imperial, can you say the same? +Beep backend auth proxy. Is basically tailored just for traefik's Forward Authentication system. It takes a `GET`, `POST`, `PUT`, `PATCH` or `DELETE` request, reads a Bearer Auth JWT token if available. Alternatively, the token can be supplied in the querystring as `token`. Tokens in the Authorization header override tokens in the querystring. If it is not available or invalid, request fails with 4XX and traefik rejects the request. Otherwise, a success response is returned with a `X-User-Claim` header containing serialised user information. `OPTIONS` requests are allowed to pass through wholesale. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5c11bb3 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module auth + +require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/joho/godotenv v1.3.0 + github.com/julienschmidt/httprouter v1.2.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2580ace --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b60ce2b --- /dev/null +++ b/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strings" + + "github.com/joho/godotenv" + "github.com/julienschmidt/httprouter" + "github.com/dgrijalva/jwt-go" +) + +var listen string +var secret []byte + +func main() { + // Load .env + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + listen = os.Getenv("LISTEN") + secret = []byte(os.Getenv("SECRET")) + + // Routes + router := httprouter.New() + router.OPTIONS("/auth", PassThrough) + router.GET("/auth", Auth) + router.POST("/auth", Auth) + router.PUT("/auth", Auth) + router.DELETE("/auth", Auth) + router.PATCH("/auth", Auth) + + // Start server + log.Printf("starting server on %s", listen) + log.Fatal(http.ListenAndServe(listen, router)) +} + +func PassThrough(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + w.WriteHeader(http.StatusOK) +} + +type UserClaims struct { + UserId string `json:"userid"` + ClientId string `json:"clientid"` +} +func Auth(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + tokenString := "" + + // Extract token from querystrign in X-Forwarded-Uri + uri, err := url.Parse(r.Header.Get("X-Forwarded-Uri")) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + queries, err := url.ParseQuery(uri.RawQuery) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + if len(queries["token"]) > 0 { + tokenString = queries["token"][0] + } + + // Extract token from Authorization header + reqToken := r.Header.Get("Authorization") + splitToken := strings.Split(reqToken, "Bearer ") + if len(splitToken) > 1 { + tokenString = splitToken[1] + } + + // Make sure token string is not null + if tokenString == "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Parse token + token, err := jwt.Parse(tokenString, 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 secret, nil + }) + if err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + if !token.Valid { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + userclaims := UserClaims { + UserId: claims["userid"].(string), + ClientId: claims["clientid"].(string), + } + + userclaimBytes, err := json.Marshal(&userclaims) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + w.Header().Set("X-User-Claim", (string)(userclaimBytes)) + w.WriteHeader(http.StatusOK) +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a94377a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,102 +0,0 @@ -#[macro_use] -extern crate serde; -extern crate serde_json; -extern crate serde_qs; -#[macro_use] -extern crate lazy_static; -extern crate dotenv; -extern crate iron; -extern crate router; -extern crate jsonwebtoken as jwt; -extern crate urlencoded; - -use dotenv::dotenv; -use iron::prelude::*; -use iron::headers::{ Authorization, Bearer }; -use iron::status::Status; -use router::Router; -use urlencoded::{ UrlDecodingError, UrlEncodedQuery }; -use std::env; - -#[derive(Debug, Serialize, Deserialize)] -struct UserClaims { - userid: String, - clientid: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct TokenString { - token: Option, -} - -fn main() { - dotenv().ok(); - - let listen = env::var("LISTEN").unwrap(); - - lazy_static! { - static ref secret:String = env::var("SECRET").unwrap(); - } - - fn verify_jwt(req:&mut Request) -> IronResult { - let mut token = None; - - // Check token from querystring of forwarded URI - if let Some(forwarded_uri) = req.headers.get_raw("X-Forwarded-Uri") { - if !forwarded_uri.is_empty() { - let forwarded_uri = match std::str::from_utf8(&forwarded_uri[0]) { - Ok(uri) => uri, - Err(_) => return Ok(Response::with((Status::BadRequest, "400 Bad Request"))), - }; - let qs:TokenString = match serde_qs::from_str(forwarded_uri) { - Ok(qs) => qs, - Err(_) => return Ok(Response::with((Status::BadRequest, "400 Bad Request"))), - }; - token = qs.token; - } - } - - // Check token from Authorization header - if let Some(authorisation_header) = req.headers.get::>() { - token = Some(authorisation_header.token.clone()); - } - - // Process token - if let Some(token) = token { - match jwt::decode::(&token, secret.as_ref(), &jwt::Validation{ validate_exp:false, ..Default::default()}) { // Don't validate expiry - // match jwt::decode::(&authorisation_header.token, secret.as_ref(), &jwt::Validation::default()) { // Production version - Ok(decoded) => { - let user_string = match serde_json::to_string(&decoded.claims) { - Ok(s) => s, - Err(_) => return Ok(Response::with((Status::Unauthorized, "401 Unauthorised"))), - }; - let mut res = Response::with((Status::Ok, "200 Ok")); - res.headers.set_raw("X-User-Claim", vec![user_string.as_bytes().to_vec()]); - Ok(res) - }, - Err(_) => Ok(Response::with((Status::Unauthorized, "401 Unauthorised"))) - } - } else { - Ok(Response::with((Status::BadRequest, "400 Bad Request"))) - } - } - - let mut router = Router::new(); - // Let OPTIONS through - router.options("/auth", success, "auth_options"); - // Auth GET, POST, PUT, PATCH, DELETE - router.get("/auth", verify_jwt, "auth_get"); - router.post("/auth", verify_jwt, "auth_post"); - router.put("/auth", verify_jwt, "auth_put"); - router.patch("/auth", verify_jwt, "auth_patch"); - router.delete("/auth", verify_jwt, "auth_delete"); - - match Iron::new(router).http(&listen) { - Ok(_) => println!("Listening on {}", &listen), - Err(e) => println!("Error: {:?}", e), - }; -} - -fn success(_:&mut Request) -> IronResult { - Ok(Response::with((Status::Ok, ""))) -}