I am working on a full stack app using NodeJS and Flutter For Web, at the moment i don't understand how to make safe cookie/token sessions.
The answer i need is how to make an authentication system with Flutter For Web like other Social Networks or Stackoverflow itself.
Importing dart.html directly doesn't support from flutter 1.9 : Reference
I came across the package universal_html while digging in for the solution, and its working fine for me. Below is my helper class to store key-value pair locally on web:
import 'package:universal_html/prefer_universal/html.dart';
class WebStorage {
//Singleton
WebStorage._internal();
static final WebStorage instance = WebStorage._internal();
factory WebStorage() {
return instance;
}
String get sessionId => window.localStorage['SessionId'];
set sessionId(String sid) => (sid == null) ? window.localStorage.remove('SessionId') : window.localStorage['SessionId'] = sid;
}
To read,
WebStorage.instance.sessionId;
To write,
WebStorage.instance.sessionId = 'YOUR_CREDENTIAL';
Example:
fetchPost(params, "CMD_USERREGISTRATION").then((result) {
...
APIResponse response = APIResponse(xmlString: result.body);
if (!response.isSuccess()) {
...
return;
}
var sid = response.getSessionId();
if (kIsWeb) {
WebStorage.instance.sessionId = sid;
}
}
main.dart:
#override
Widget build(BuildContext context) {
if (kIsWeb) {
isLogin = WebStorage.instance.sessionId != null;
} else {
isLogin = //check from SharedPreferences;
}
return isLogin ? dashboardPage() : loginPage();
}
UPDATE:
shared_preferences now support web from the version 0.5.6. See also shared_preferences_web
This is an old question but the chosen answer is not totally safe.
Using web storage for sensitive information is not safe, for the web.
You should use http-only cookies. Http-only cookies cannot be read via Javascript but the browser automatically sends it to the backend.
Here is example of Nodejs-Express-TypeScript Code;
In this example there two cookies one is http-only, the other one is not.
The non-http-only cookie is for checking logged-in situation, if it's valid client assumes the user is logged in. But the actual control is done by the backend.
PS: There is no need to store or send any token, because browser handles it (cookies) automatically.
const cookieConfig = {
httpOnly: true,
secure,
maxAge: 30 * 24 * 60 * 60 * 1000,
signed: secure,
}
const cookieConfigReadable = {
httpOnly: false,
secure,
maxAge: 30 * 24 * 60 * 60 * 1000,
signed: secure,
}
function signToken(unsignedToken: any) {
const token = jwt.sign(unsignedToken, privatekey, {
algorithm: 'HS256',
expiresIn: jwtExpirySeconds,
})
return token
}
const tokenObj = {
UID: userId,
SID: sessionId,
}
const token = signToken(tokenObj)
// sets session info to the http-only cookie
res.cookie('HSINF', token, cookieConfig)
// sets a cookie with expires=false value for client side check.
res.cookie('expired', false, cookieConfigReadable)
But in this approach there is a challenge during debugging, because NodeJS and Flutter Web serves on different ports, you should allow CORS for dev environment.
if (process.env.NODE_ENV === 'dev') {
app.use(
cors({
origin: [
'http://localhost:8080',
'http://127.0.0.1:8080',
],
credentials: true,
}),
)
}
And in Flutter Web you cannot use http.get or http.post directly during debugging, because flutter disables CORS cookies by default.
Here is a workaround for this.
// withCredentials = true is the magic
var client = BrowserClient()..withCredentials = true;
http.Response response;
try {
response = await client.get(
Uri.parse(url),
headers: allHeaders,
);
} finally {
client.close();
}
Also in debugging there is another challenge; many browsers does not allow cookies for localhost, you should use 127.0.0.1 instead. However Flutter Web only runs for certain url and certain port, so here is my VsCode configuration for Flutter to run on 127.0.0.1
{
"name": "project",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"-d",
"chrome",
"--web-port",
"8080",
"--web-hostname",
"127.0.0.1"
]
}
These were for setting and transferring cookie, below you can find my backend cookie check
const httpOnlyCookie = req.signedCookies.HSINF
const normalCookie = req.signedCookies.expired
if (httpOnlyCookie && normalCookie === 'false') {
token = httpOnlyCookie
}
if (token) {
let decoded: any = null
try {
decoded = jwt.verify(token, privatekey)
} catch (ex) {
Logger.error(null, ex.message)
}
if (decoded) {
//Cookie is valid, get user with the session id
}
}
You can encrypt it as you want then use getStorage to store it & it supports all platforms, example:
GetStorage box = GetStorage();
write it:
box.write('jwt', 'value');
read it:
if (box.hasData('jwt')) {
box.read('jwt');
}
Related
I'm using cookie storage session to hold user's token which is received from authentication. When I'm trying to set it after login and call it from the root.tsx's Loader Function, the userId is returned as undefined.
My loader function is:
export let loader: LoaderFunction = async({request, params}) => {
let userId = await getUserId(request);
console.log(userId);
return (userId ? userId : null);
}
The function which I receive the userId getUserId is defined as:
export async function getUserId(request: Request){
let session = await getUserSession(request);
let userId = session.get("userId");
if (!userId || typeof userId !== "string") return null;
return userId;
}
The getUserSession function is as:
export async function getUserSession(request: Request){
return getSession(request.headers.get('Cookie'));
}
I receive the getSession from destructring createCookieSessionStorage.
I'm creating a cookie with createUserSession function which is like:
export async function createUserSession(userId: string, redirectTo: string){
let session = await getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
I also receive the commitSession from destructing createCookieSessionStorage. I used the same code from the Jokes demo app.
let { getSession, commitSession, destroySession } = createCookieSessionStorage({
cookie: {
name: "RJ_session",
secure: true,
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
You configured the cookie to have secure: true, this makes the cookie only work with HTTPS, while some browsers allow secure cookies in localhost most don't do it so that may be causing your cookie to not be saved by the browser, making subsequent requests not receive the cookie at all.
Upon visiting/refresh, the app checks for a refresh token in the cookie. If there is a valid one, an access token will be given by the Apollo Express Server. This works fine on my desktop but when using Chrome or Safari on the iPhone, the user gets sent to the login page on every refresh.
React App with Apollo Client
useEffect(() => {
fetchUser();
}, []);
const fetchUser = async () => {
const res = await fetch('https://website.com/token', {
method: 'POST',
credentials: 'include',
});
const { accessToken } = await res.json();
if (accessToken === '') {
setIsLoggedIn(false);
}
setAccessToken(accessToken);
setLoading(false);
};
Apollo Client also checks if whether the access token is valid
const authLink = setContext((_, { headers }) => {
const token = getAccessToken();
if (token) {
const { exp } = jwtDecode(token);
if (Date.now() <= exp * 1000) {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
}
}
fetch('https://website.com/token', {
method: 'POST',
credentials: 'include',
}).then(async (res) => {
const { accessToken } = await res.json();
setAccessToken(accessToken);
return {
headers: {
...headers,
authorization: accessToken ? `Bearer ${accessToken}` : '',
},
};
});
});
const client = new ApolloClient({
link: from([authLink.concat(httpLink)]),
cache: new InMemoryCache(),
connectToDevTools: true,
});
This handles the token link on the Express server
app.use('/token', cookieParser());
app.post('/token', async (req, res) => {
const token = req.cookies.rt;
if (!token) {
return res.send({ ok: false, accessToken: '' });
}
const user = await getUser(token);
if (!user) {
return res.send({ ok: false, accessToken: '' });
}
sendRefreshToken(res, createRefreshToken(user));
return res.send({ ok: true, accessToken: createAccessToken(user) });
});
And setting of the cookie
export const sendRefreshToken = (res, token) => {
res.cookie('rt', token, {
httpOnly: true,
path: '/token',
sameSite: 'none',
secure: true,
});
};
Same site is 'none' as the front end is on Netlify.
After a day of fiddling and researching, I have found the issue, and one solution when using a custom domain.
The issue is that iOS treats sameSite 'none' as sameSite 'strict'. I thought iOS Chrome would be different than Safari but it appears not.
If you use your front-end, hosted on Netlify, you will naturally have a different domain than your Heroku app back-end. Since I am using a custom domain, and Netlify provides free SSL, half of the work is done.
The only way to set a httpOnly cookie is to set the cookie to secure. The next step would be to set sameSite to 'none' but as mentioned above, this does not work with iOS.
Setting the domain property of the cookie will also not work because the domain property concerns the scope of the cookie and not the cookie origin. If the cookie came from a different domain (Heroku backend), then the frontend (on Netlify) will not be able to use it.
By default, on Heroku, the free dyno will give you a domain like 'your-app.herokuapp.com', which is great because it also includes free SSL. However, for the cookie to work, I added my custom domain that I use with Netlify. To be clear, Netlify already uses my apex custom domain, so I am adding a subdomain to Heroku (api.domain.com). Cookies do work for across the same domain and subdomains with sameSite 'strict'.
The final issue with this is that the custom domain with Heroku will not get SSL automatically, which is why I think it is worth it to upgrade to a $7/month hobby dyno to avoid managing the SSL manually. This I think is the only solution when using a custom domain.
On the other hand, for those who have the same issue and would like a free solution, you can forgo using a custom domain and host your static front-end with the back-end on Heroku.
Hopefully this will save some time for anyone deploying the back-end and front-end separately.
I noticed that the "cors" check takes longer than I expected. This has happened at different speeds from localhost, qa and production.
I am using axios (^0.18.0) that is using mobx/MST/reactjs and asp.net core api 2.2
I can have preflight options that range from a 20 milliseconds to 10 seconds and it will randomly change.
For instance I have
https://localhost:44391/api/Countries
This is a get request and it can take 20 milliseconds 9 times in a row (me ctrl + F5) but on the 10th time it decides to take seconds (I don't get seconds really on localhost but sometimes a second).
So this test, the 204 (cors request) takes 215ms where the actual request that brings back the data takes half the time. This seems backwards.
This is my ajax request
const axiosInstance = axios.create({
baseURL: 'https://localhost:44391/api/Countries',
timeout: 120000,
headers: {
contentType: 'application/json',
}})
axiosInstance.get();
Here is my startup. I made cors all open and wanted to refine it after I get this issue solved.
public class Startup
{
public IHostingEnvironment HostingEnvironment { get; }
private readonly ILogger<Startup> logger;
public Startup(IConfiguration configuration, IHostingEnvironment env, ILogger<Startup> logger)
{
Configuration = configuration;
HostingEnvironment = env;
this.logger = logger;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddCors();
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseLazyLoadingProxies();
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"));
});
services.AddIdentity<Employee, IdentityRole>(opts =>
{
opts.Password.RequireDigit = false;
opts.Password.RequireLowercase = false;
opts.Password.RequireUppercase = false;
opts.Password.RequireNonAlphanumeric = false;
opts.Password.RequiredLength = 4;
opts.User.RequireUniqueEmail = true;
}).AddEntityFrameworkStores<ApplicationDbContext>().AddDefaultTokenProviders();
services.AddAuthentication(opts =>
{
opts.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(cfg =>
{
cfg.RequireHttpsMetadata = false;
cfg.SaveToken = true;
cfg.TokenValidationParameters = new TokenValidationParameters()
{
// standard configuration
ValidIssuer = Configuration["Auth:Jwt:Issuer"],
ValidAudience = Configuration["Auth:Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration["Auth:Jwt:Key"])),
ClockSkew = TimeSpan.Zero,
// security switches
RequireExpirationTime = true,
ValidateIssuer = true,
ValidateIssuerSigningKey = true,
ValidateAudience = true
};
});
services.AddAuthorization(options =>
{
options.AddPolicy("CanManageCompany", policyBuilder =>
{
policyBuilder.RequireRole(DbSeeder.CompanyAdminRole, DbSeeder.SlAdminRole);
});
options.AddPolicy("CanViewInventory", policyBuilder =>
{
policyBuilder.RequireRole(DbSeeder.CompanyAdminRole, DbSeeder.SlAdminRole, DbSeeder.GeneralUserRole);
});
options.AddPolicy("AdminArea", policyBuilder =>
{
policyBuilder.RequireRole(DbSeeder.SlAdminRole);
});
});
// do di injection about 30 of these here
services.AddTransient<IService, MyService>();
services.AddSingleton(HostingEnvironment);
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddTransient<IValidator<CompanyDto>, CompanyDtoValidator> ();
services.AddTransient<IValidator<BranchDto>, BranchDtoValidator>();
services.AddTransient<IValidator<RegistrationDto>, RegistrationDtoValidator>();
JsonConvert.DefaultSettings = () => {
return new JsonSerializerSettings()
{
NullValueHandling = NullValueHandling.Ignore,
MissingMemberHandling = MissingMemberHandling.Ignore,
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
};
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseHsts();
}
//TODO: Change this.
app.UseCors(builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseMvc();
}
}
I don't know if this is a valid test, but it does mimic what I am seeing in qa/production at random times.
I changed the axios request to ...
const axiosInstance = axios.create({
baseURL: 'https://localhost:44391/api/Countries/get',
timeout: 120000,
headers: {
contentType: 'application/json',
}})
axiosInstance.get();
basically I put /get, which causes a 404
Yet, when I refresh my page with the exact same scenario it is completed in milliseconds again (though still slower than the 404)
Edit
I made a hosted site nearly identical to my real site. The only difference is this one is only using http and not https.
http://52.183.76.195:82/
It is not as slow as my real site, but the preflight right now can take 40ms while the real request takes 50ms.
I am testing it in latest version of chrome and you will have to load up the network tab and load/click the button (no visual output is displayed).
I'm not sure if you are using a load balancer or some other proxy service, but one quick fix would be to move the CORS response headers to the load balancer level. This will minimize the overhead that your app may add at any given time. How to setup nginx as a cors proxy service can be found in How to enable CORS in Nginx proxy server?
Recently I tried to use Azure Front Door as a load balancer to solve this issue of CORS.
You can read about it here:
https://www.devcompost.com/post/using-azure-front-door-for-eliminating-preflight-calls-cors
Although I used Azure Front Door but you can use any load balancer which can repurposed as an ingress controller (like ng-inx) to solve the same problem.
The basic premise is that I am load balancing the UI hosted domain and the APIs under a same domain, thereby tricking the Browser into thinking it's a same origin call. Hence the OPTIONS request is not made anymore.
The question must be very typical, but I can't really find a good comparison.
I'm new to Ionic & mobile dev.
We have a REST API (Spring Boot).
API is currently used by AngularJS 1.5 front-end only.
AngularJS app is authenticated based on the standard session-based authentication.
What should I use to authenticate an ionic 3 app?
As I understand, have 2 options:
Use the same auth as for Angular front-end.
implement oauth2 on the back-end and use the token for the ionic app.
As for now, I understand that implementing oauth2 at back-end is a way to go because with the option #1 I should store the username & password in the local storage (ionic app), which is not safe. Otherwise, if I don't do that - the user will have to authenticate each time the app was launched. Am I right?
So, that leaves me with option #2 - store oauth2 token on the device?
Good to go with #2. Here is how i manage token.
I use ionic storage to store token and a provider config.ts which hold the token during run time.
config.ts
import { Injectable } from '#angular/core';
#Injectable()
export class TokenProvider {
public token: any;
public user: any = {};
constructor( ) { }
setAuthData (data) {
this.token = data.token;
this.user = data
}
dropAuthData () {
this.token = null;
this.user = null;
}
}
auth.ts
import { TokenProvider} from '../../providers/config';
constructor(public tokenProvider: TokenProvider) { }
login() {
this.api.authUser(this.login).subscribe(data => {
this.shared.Loader.hide();
this.shared.LS.set('user', data);
this.tokenProvider.setAuthData(data);
this.navCtrl.setRoot(TabsPage);
}, err => {
console.log(err);
this.submitted = false;
this.shared.Loader.hide();
this.shared.Toast.show('Invalid Username or Password');
this.login.password = null;
});
}
and i do a check when app launch.
app.component.ts (in constructor)
shared.LS.get('user').then((data: any) => {
if (!data) {
this.rootPage = AuthPage;
} else {
tokenProvider.setAuthData(data);
this.rootPage = TabsPage;
}
});
api.provider.ts
updateUser(data): Observable < any > {
let headers = new Headers({
'Content-Type': 'application/json',
'X-AUTH-TOKEN': (this.tokenProvider.token)
});
return this.http.post(`${baseUrl}/updateUser`, JSON.stringify(data), {
headers: headers
})
.map((response: Response) => {
return response.json();
})
.catch(this.handleError);
}
And last logout.ts
logOut(): void {
this.shared.Alert.confirm('Do you want to logout?').then((data) => {
this.shared.LS.remove('user').then(() => {
this.tokenProvider.dropAuthData();
this.app.getRootNav().setRoot(AuthPage);
}, () => {
this.shared.Toast.show('Oops! something went wrong.');
});
}, err => {
console.log(err);
})
}
The final solution i've made:
ionic app:
implemented a jwt token storage similar to Swapnil Patwa answer.
Spring back-end:
Tried to use their original ouath2 package, but found out that as always with spring/java, configs are too time-consuming => made a simple filter which is checking for the manually generated & assigned jwt token.
I have a Backbone app that queries my Express server with a un/pw, authenticates, then sends the account info (from MongoDB) along with the new sessionID back to the client. When i need more data, i attach the session id to the .fetch() options. However, Express creates a new session, even though my session was stored in Redis successfully.
Here is the middleware that checks if the client is trying to work with my api
var _restrictApi = function(req, res, next) {
if (req.url.match(/api/)) {
res.xhrAuthValid = req.param('sessionId') == req.sessionID;
if (res.xhrAuthValid || (req.method=='GET' && req.url.match(/api\/account/))) {
console.log('API access granted', req.url);
console.dir(req.session);
next();
} else {
console.log('API access BLOCKED', req.url);
console.log(req.param('sessionId'), req.sessionID);
console.dir(req.session);
res.send(403, 'Forbidden');
}
} else {
next();
}
};
My Backbone app makes a few .fetch() calls upon loading. First, log-in, then grab events for the user. Here is the Express server console log:
API access granted /api/account?email=test%40gmail.com&password=somepw
{ cookie:
{ path: '/',
_expires: null,
originalMaxAge: null,
httpOnly: true } }
_checkAccount test#gmail.com
pw matches
{ cookie:
{ path: '/',
_expires: null,
originalMaxAge: null,
httpOnly: true },
account:
{ _id: 500471eb8bfff124ce984917,
dtAdd: '2012-07-16T20:07:58.671Z',
email: 'test#gmail.com',
pwHash: '$2a$10$2KJXrZeAGW58Kp9JQDL9B.K2Fvu2oE3oqWKRl55o8MeXGHA/zCBE.',
sessionId: 'iqYjOA7CeQHny9cm8zOWERjv' } }
API access BLOCKED /api/events?accountId=500471eb8bfff124ce984917&sessionId=iqYjOA7CeQHny9cm8zOWERjv
iqYjOA7CeQHny9cm8zOWERjv rsSXKtzXNNiq8x3+pUN9JXWF
{ cookie:
{ path: '/',
_expires: null,
originalMaxAge: null,
httpOnly: true } }
Create a connect.sid by signing the sessionID:
cookie = require('cookie-signature')
res.send('connect.sid=s%3A'+cookie.sign(req.sessionID, req.secret))
Store it in local storage and set the Cookie header for each AJAX request:
r.setRequestHeader('Cookie', window.localStorage['connect.sid'])
That works.