teodevgroup / teo

Schema-driven web server framework.
https://teodev.io
Apache License 2.0
1.41k stars 46 forks source link

User security question #61

Closed akelyasir closed 4 months ago

akelyasir commented 6 months ago

Hi @victorteokw

I would like to receive information about user security. You use jwt token for the user login part and we need to keep it on localstorage. Localstorage is open to XSS attacks, how can we ensure this security? Does calling and checking user information for each handler along with the token ensure security? Of course, permission will be set for user information. Can you suggest a different method? Or wouldn't it make more sense to use cookie auth? When I examine the system, it seems like a lot of settings need to be made for cookie auth, maybe I'm thinking wrong. I want to ensure user security. I hope you can help with this.

Thanks

victorteokw commented 6 months ago

Hi @akelyasir, cookie will be added.

akelyasir commented 6 months ago

@victorteokw

Thank you. Is it possible to use both bearer and cookie at the same time for certain conditions? For example, while users use Bearer token or Basic auth in API distribution, they can use cookie session within the project.

victorteokw commented 5 months ago

Hi @akelyasir, yes, this feature will be added in 0.3.0 and it's coming soon.

victorteokw commented 5 months ago

Hi @akelyasir, the 0.3.0-dev branch is created today. The new version will fix this issue.

akelyasir commented 5 months ago

Hi @victorteokw When will there be an update for Cookie?

victorteokw commented 5 months ago

Hi @akelyasir, in a week. Rust, Node.js and Python will have Cookie features. Currently you can access request cookie from the Rust API.

akelyasir commented 5 months ago

Hi @victorteokw Can you give an example of how to create and read cookies for the Rust API? You said it supports Rust API. Thanks.

akelyasir commented 5 months ago

Hi @victorteokw

I tried some changes today and it worked. I want to share with you the changes I made. To make sure this is the right solution.

1- I added a cookie to the teo-runtime requirements.

2- teo-runtime > src > response > response.rs I updated the file as follows.

-------// I HAVE EDITED THIS AREA--------
use cookie::Cookie;
------------------------------------------------------
impl Response {

    pub fn empty() -> Response {
        Self {
            inner: Arc::new(Mutex::new(ResponseInner::new())),
        }
    }

    pub fn string(content: impl Into<String>, content_type: &str) -> Response {
        let mut inner = ResponseInner::new();
        inner.body = Body::string(content.into());
        inner.headers.set("content-type", content_type);
        Self {
            inner: Arc::new(Mutex::new(inner)),
        }
    }

    pub fn teon(value: Value) -> Response {
        let mut inner = ResponseInner::new();
        inner.body = Body::teon(value);
        Self {
            inner: Arc::new(Mutex::new(inner))
        }
    }

    pub fn html(content: impl Into<String>) -> Result<Response> {
        Ok(Self::string(content.into(), "text/html"))
    }

    pub fn data(value: Value) -> Response {
        Self::teon(teon!({"data": value}))
    }

    pub fn data_meta(data: Value, meta: Value) -> Response {
        Self::teon(teon!({"data": data, "meta": meta}))
    }

    pub fn error(error: impl Into<teo_result::Error>) -> Response {
        let path_error = error.into();
        let code = path_error.code;
        let value: Value = path_error.into();
        let res = Self::teon(value);
        res.set_code(code);
        res
    }

    pub fn file(path: PathBuf) -> Response {
        let res = Self::empty();
        res.inner.lock().unwrap().body = Body::file(path);
        res
    }

    pub fn redirect(path: impl Into<String>) -> Response {
        let res = Self::empty();
        res.set_code(301);
        res.headers().set("location", path.into());
        res
    }

    pub fn set_code(&self, code: u16) {
        self.inner.lock().unwrap().code = code;
    }
   -------// I HAVE EDITED THIS AREA--------
    pub fn set_cookie(&mut self, cookie: &Cookie<'_>) {
        self.inner.lock().unwrap().headers.set("set-cookie", cookie.to_string())
    }
   ------------------------------------------------------------------

    pub fn code(&self) -> u16 {
        self.inner.lock().unwrap().code
    }

    pub fn headers(&self) -> HeaderMap {
        self.inner.lock().unwrap().headers.clone()
    }

    pub fn body(&self) -> Body {
        self.inner.lock().unwrap().body.clone()
    }
}

3- teo-runtime > src > stdlib > indentity > mod.rs

identity_namespace.define_handler_template("signIn", |req_ctx: request::Ctx| async move {
        let model = req_ctx.namespace().model_at_path(&req_ctx.handler_match().path()).unwrap();
        let model_ctx = req_ctx.transaction_ctx().model_ctx_for_model_at_path(&req_ctx.handler_match().path()).unwrap();
        let input = req_ctx.body();
        let credentials = input.get("credentials").unwrap().as_dictionary().unwrap();
        let mut identity_key: Option<&String> = None;
        let mut identity_value: Option<&Value> = None;
        let mut checker_key: Option<&String> = None;
        let mut checker_value: Option<&Value> = None;
        let mut checker_field: Option<&Field> = None;
        let mut companion_values: IndexMap<String, Value> = indexmap!{};
        let mut id_values: IndexMap<String, Value> = indexmap!{};
        let id_fields: Vec<&Field> = model.fields.values().filter(|f| f.data.get("identity:id").is_some()).collect();
        if id_fields.len() == 0 {
            return Err(Error::internal_server_error_pathed(path!["credentials"], "no @identity.id defined on this model"));
        }
        let checker_fields: Vec<&Field> = model.fields.values().filter(|f| f.data.get("identity:checker").is_some()).collect();
        let companion_fields: Vec<&Field> = model.fields.values().filter(|f| f.data.get("identity:companion").is_some()).collect();
        for (k, v) in credentials {
            if let Some(f) = id_fields.iter().find(|f| f.name() == k.as_str()) {
                id_values.insert(k.to_string(), v.clone());
                if identity_key.is_none() {
                    identity_key = Some(k);
                    identity_value = Some(v);
                } else {
                    return Err(Error::invalid_request_pathed(path!["credentials", k.as_str()], "multiple @identity.id value received"));
                }
            }
            if let Some(f) = checker_fields.iter().find(|f| f.name() == k.as_str()) {
                if checker_key.is_none() {
                    checker_key = Some(k);
                    checker_value = Some(v);
                    checker_field = Some(*f);
                } else {
                    return Err(Error::invalid_request_pathed(path!["credentials", k.as_str()], "multiple @identity.checker value received"));
                }
            }
            if let Some(_) = companion_fields.iter().find(|f| f.name() == k.as_str()) {
                companion_values.insert(k.to_string(), v.clone());
            }
        }
        if identity_key == None {
            return Err(Error::invalid_request_pathed(path!["credentials"], "missing @identity.id value"));
        } else if checker_key == None {
            return Err(Error::invalid_request_pathed(path!["credentials"], "missing @identity.checker value"));
        }
        let object: Option<model::Object> = model_ctx.find_unique(&teon!({
            "where": {
                identity_key.unwrap(): identity_value.unwrap()
            }
        })).await?;
        let Some(object) = object else {
            return Err(Error::not_found_pathed(path!["credentials"], "this identity is not found"));
        };
        let auth_checker_pipeline = checker_field.unwrap().data.get("identity:checker").unwrap().as_pipeline().unwrap();
        let pipeline_input = teon!({
            "value": checker_value.unwrap(),
            "companions": companion_values,
            "ids": id_values,
        });
        let pipeline_ctx = pipeline::Ctx::new(Value::from(pipeline_input), object.clone(), path!["credentials"], CODE_NAME | CODE_AMOUNT | CODE_POSITION, req_ctx.transaction_ctx(), Some(req_ctx.clone()));
        let _ = pipeline_ctx.run_pipeline_ignore_return_value(auth_checker_pipeline).await.map_err(|mut e| { e.code = 401; e})?;
        let credentials_pipeline_ctx = pipeline::Ctx::new(Value::from(Value::Dictionary(credentials.clone())), object.clone(), path!["credentials"], CODE_NAME | CODE_AMOUNT | CODE_POSITION, req_ctx.transaction_ctx(), Some(req_ctx.clone()));
        let self_pipeline_ctx = pipeline::Ctx::new(Value::from(&object), object.clone(), path![], CODE_NAME | CODE_AMOUNT | CODE_POSITION, req_ctx.transaction_ctx(), Some(req_ctx.clone()));
        if let Some(validator) = model.data.get("identity:validateAccount") {
            let validator = validator.as_pipeline().unwrap();
            match self_pipeline_ctx.run_pipeline_ignore_return_value(validator).await {
                Ok(_) => (),
                Err(mut error) => {
                    error.code = 401;
                    return Err(error);
                }
            }
        }
        let Some(token_issuer) = model.data.get("identity:tokenIssuer") else {
            return Err(Error::internal_server_error_message("missing identity token issuer"));
        };
        let token_issuer = token_issuer.as_pipeline().unwrap();
        let token_string: String = credentials_pipeline_ctx.run_pipeline(token_issuer).await?;
        // Output to the client
        let include = input.get("include");
        let select = input.get("select");
        let obj = object.refreshed(include, select).await?;
        let obj_teon = obj.to_teon().await?;
        -------// I HAVE EDITED THIS AREA--------
        let cookie = Cookie::build(("sid", token_string.clone())).path("/").secure(true).http_only(true).same_site(cookie::SameSite::Lax).build();
        let mut res = Response::data_meta(obj_teon, teon!({
            "token": token_string
        }));
        res.set_cookie(&cookie);
        Ok(res)
      -------------------------------------------------------------------
    });
identity_namespace.define_handler_template("identity", |req_ctx: request::Ctx| async move {
        let model = req_ctx.namespace().model_at_path(&req_ctx.handler_match().path()).unwrap();
        let model_ctx = req_ctx.transaction_ctx().model_ctx_for_model_at_path(&req_ctx.handler_match().path()).unwrap();
        let Some(jwt_secret) = model.data.get("identity:jwtSecret") else {
            return Err(Error::internal_server_error_message("missing @identity.jwtSecret"));
        };
        let jwt_secret: String = jwt_secret.try_into()?;

        ----------// I HAVE EDITED THIS AREA------------------------------------
        let mut token = "";
        if let Some(authorization) = req_ctx.request().headers().get("authorization") {
            token = authorization
                .strip_prefix("Bearer ")
                .unwrap_or(authorization);
        }
        let cookies = req_ctx.request().cookies().ok();

        for cookie in cookies.as_deref().unwrap_or_default() {
            if cookie.inner.name() == "sid" {
                token = cookie.inner.value();
            }
        };
       -------------------------------------------------------------------------------------------
        if token.len() < 7 {
            return Err(Error::unauthorized_message("invalid jwt token"));
        }
        let Ok(claims) = decode_token(token, jwt_secret.as_str()) else {
            return Err(Error::unauthorized_message("invalid jwt token"));
        };
        if claims.model != model.path {
            return Err(Error::unauthorized_message("wrong model of identity"));
        }
        let teon_value: Value = Value::from(claims.id);
        let object: Option<model::Object> = model_ctx.find_unique(&teon_value).await?;
        if let Some(object) = object {
            let include = req_ctx.body().get("include");
            let select = req_ctx.body().get("select");
            let obj = object.refreshed(include, select).await?;
            let obj_teon = obj.to_teon().await?;
            return Ok(Response::data_meta(obj_teon, teon!({
                "token": token
            })));
        } else {
            return Err(Error::unauthorized_message("identity not found"));
        }
    });

I made the changes as above and was able to successfully verify the jwt token with cookies. In this way, I was able to use both Bearer and cookie authentication together or separately. Are these procedures appropriate? or can a better method be used?

victorteokw commented 5 months ago

Hi @akelyasir, could you share your idea on how to validate user token with cookie? Why you use cookie together with jwt token? Could you create a pull request? And it won't be merged. Just I can read your code more easily. I will come out an idea for your need.

akelyasir commented 5 months ago

Hi @victorteokw

Currently, verification can be done with jwt token via cookie. I implemented this in /User/identity. But I haven't done anything about how to do it for other pages yet.I used jwt for cookie because if I used any other structure other than jwt, I would have to use any storage system(redis, sql, memory) for authentication. I made a pull request, you can check it out. I'm looking forward to your suggestions.

While testing I applied the following scheme.

connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}

server {
  bind: ("0.0.0.0", 5052)
}

@identity.tokenIssuer($identity.jwt(expired: 60))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  email: String
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker($get(.value).presents.bcrypt.verify($self.get(.password).presents))
  password: String

  include handler identity.signIn
  include handler identity.identity
}

middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]
victorteokw commented 4 months ago

Hi @akelyasir, currently you can write custom pipeline item and decorators as a workaround. I designed a new API to support this in the 0.3.0 version.