Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

On Authentication Success

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When our AJAX call to authenticate is successful, our app naturally sends back a session cookie, which all future AJAX calls will automatically use to become authenticated. So then... if our API response data doesn't need to contain a token... what should it contain?

Return the User as JSON on Success?

One option is to return the authenticated User object as JSON. For example... I think one of my users in the database is id 5 - so if you go to /api/users/5.json, we could return this JSON. Or even better we could return the JSON-LD representation of a User.

This has the benefit of being useful: our JavaScript will then know some info about who just logged in. But... if you want to get technical about things... this solution isn't RESTful: it sort of turns our authentication endpoint into what looks like a "user" resource. But, don't let that get in your way: if you do want to return the User object as JSON, you can serialize it manually and return it. I'll show you how to use the serializer to do this in the next chapter.

But... I have another suggestion.

Returning the User IRI

What if we returned the IRI - /api/users/5 - which is also the URL that a client can use to get more info about that user? Let's try that!

At the bottom of the controller, return a new Response() - the one from HttpFoundation - with no content: literally pass this null. Returning an empty response is totally valid, as long as you use a 204 status code, which means:

The request was successful... but I have nothing to say to you!

So... where are we putting the IRI? On the Location header! That's a semi-standard way for an API to point to a resource. For the IRI string... hmm... how can we generate the URL to /api/users/5? Typically in Symfony, we create the route and then we can generate a URL to that route by using its internal name. But... now, API Platform is creating the routes for us. Is there a way with API Platform to say:

Hey! Can you generate the IRI to this exact object?

Yep! And it's a useful trick to know. Add an argument to your controller with the IriConverterInterface type-hint. Now, set the Location header to $iriConverter->getIriFromItem() - which is one of a few useful methods on this class - and pass $this->getUser().

... lines 1 - 4
use ApiPlatform\Core\Api\IriConverterInterface;
... lines 6 - 8
use Symfony\Component\HttpFoundation\Response;
... lines 10 - 11
class SecurityController extends AbstractController
{
... lines 14 - 16
public function login(IriConverterInterface $iriConverter)
{
... lines 19 - 24
return new Response(null, 204, [
'Location' => $iriConverter->getIriFromItem($this->getUser())
]);
}
}

Cool! Let's see what this look like! Go back to LoginForm.vue. Right now, on success, we're logging response.data. Change that to response.headers so we can see what the headers look like.

... lines 1 - 41
axios
... lines 43 - 46
.then(response => {
console.log(response.headers);
... lines 49 - 52
}).catch(error => {
... lines 54 - 69

Back on our browser, refresh the homepage. By the way, you can see that the Vue.js app is reporting that we are not currently authenticated... even though the web debug toolbar says that we are. That's because our backend app & JavaScript aren't working together on page load to share this information. We'll fix that really soon.

When we log in this time... we get a 204 status code! Yes! And the logs contain a big array of headers with... location: "/api/users/6".

Using the IRI in JavaScript

This gives our JavaScript everything it needs: we can make a second request to this URL if we want to know info about the user. We're going to do exactly that.

Back in PhpStorm, open up CheeseWhizApp.vue. This is the main Vue file that's responsible for rendering the entire page - you can see the CheeseWhiz header stuff on top. And... further below, it embeds the LoginForm.vue component.

This also holds the logic that prints whether or not we're authenticated... via a user variable. We're not going to get too much into the details of Vue.js, but when we render the LoginForm component, we pass it a callback via the v-on attribute.

... lines 1 - 25
<div class="col-xs-12 col-md-6 px-5" style="background-color: #7FB7D7; padding-bottom: 150px;">
... line 27
<loginForm
v-on:user-authenticated="onUserAuthenticated"
></loginForm>
</div>
... lines 32 - 74

This basically means that, inside of LoginForm.vue, once the user is authenticated, we should dispatch an event called user-authenticated. When we do that, Vue will execute this onUserAuthenticated method. That accepts a userUri argument, which we then use to make an AJAX request for that user's data. On success, it updates the user property, which should cause the message on the page to change and say that we're logged in.

Phew! Let me show you what this looks inside LoginForm.vue. Uncomment the last three lines in the callback. This dispatches the user-authenticated event and passes it the user IRI that it needs. The userUri variable doesn't exist, but we know how to get that: response.headers.location. I'll take out my console.log().

... lines 1 - 41
axios
... lines 43 - 46
.then(response => {
this.$emit('user-authenticated', response.headers.location);
this.email = '';
this.password = '';
}).catch(error => {
... lines 52 - 67

Let's do this! Move over, refresh, then login as quesolover@example.com, password foo. And... oh boo:

TypeError: Cannot read property 'substr' of undefined.

My bad! I forgot that all headers are normalized and lowercased. Make sure the location header has a lowercase "L". Refresh the whole page one more time, put in the email, password and... watch the left side. Boom! It says:

You are currently authenticated as quesolover Log out.

At this point we're using session-based authentication, which is the best solution in many cases. And because we're relying on cookies for authentication, our authentication endpoint can really return... whatever is useful! Note that this also avoids the need for the very un-RESTful /me endpoint that some API's like Facebook expose as a, sort of "cheating" way for a client to get information about who you are currently logged in as.

Next - if we refreshed right now, our JavaScript would forget that we're logged in. Silly JavaScript! Let's leverage the serializer to communicate who is logged in from the server to JavaScript on page load.

Leave a comment!

62
Login or Register to join the conversation

Hi,

I am trying to do a registration and login page along with authentication with symfony and react. I am a bit confused with the part of the login where I want to authenticate the user using session id. Is it covered somewhere in this course. Also, instead of local storage in the frontend what can be used for storing the session id in the frontend so my user can access the resource?

Reply
Pavel J. Avatar
Pavel J. Avatar Pavel J. | posted 8 months ago

Hi, i have a question, if I using s symfony app only as API and want to login from other application, is it nested integrate JWT Auth? Because I following this tutorial and if i get a response from my app to other (Vue3, using axios), I cannot see a response full header, if i log it in the console. In network debug tool tab is the right one. I try to modify CORS yaml file, but nothing works.
Thanks Pavel

Reply
Alessandro D. Avatar
Alessandro D. Avatar Alessandro D. | posted 1 year ago | edited

Hi there
I was following this tutorial and trying to autowire IriConverterInterface inside the login method, but the type hint is failing and I does not suggest what I need.
I even tried to manually add the "use" like "Use ApiPlatform" I do not get suggestion and I wonder if you have any suggestion.
I do have apiPlatoform up and running as I can hi the swagger page and this is what I have in my composer.json

"api-platform/core": "^2.6",

Any idea?
thanks,
Alessandro

Reply

Hey Alessandro,

If you need to autowire a service, at first, try to find if you can autowire it via "bin/console debug:autowiring" and find any related services there. If there's no such service - you will need to create an alias for this manually to be able to autowire it.

I just ran that command and see that you should be able to autowire it with "ApiPlatform\Core\Api\IriConverterInterface", do you see the same in the command output in your project? If so, please, make sure you used the correct namespace in the class, i.e. have "use ApiPlatform\Core\Api\IriConverterInterface;" above the class.

Also, it may depends on your symfony version and its configuration. Try to autowire it in the constructor instead of the login method, still the same error? Btw, what error exactly?

Cheers!

Reply
Alessandro D. Avatar
Alessandro D. Avatar Alessandro D. | victor | posted 1 year ago

Hi Victor,
So here is what I tried to do. My class looks like this:


namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class SecurityController extends AbstractController
{
public function __construct()
{

}
public function login()
{
// return new Response(null, 204, [
// 'Location' => $iriConverter->getIriFromItem($this->getUser())
// ]);
}
}

When I start typing IriConverterInterface inside the constructor, I do not have any typehint. I start typing ApiPl..... and get nothing, which is strange as I am used to get suggested services. In this case I do not know why there is no type-hint. It seems like it cannot even see the whole ApiPlatform namespace.

When I run the command "./bin/console debug:autowiring" I get this:

ApiPlatform\Core\Action\NotFoundAction alias for "api_platform.action.not_found"
ApiPlatform\Core\Api\IdentifiersExtractorInterface alias for "api_platform.identifiers_extractor.cached"
ApiPlatform\Core\Api\IriConverterInterface alias for "api_platform.iri_converter"
ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface alias for "api_platform.formats_provider"
ApiPlatform\Core\Api\ResourceClassResolverInterface alias for "api_platform.resource_class_resolver"
ApiPlatform\Core\Api\UrlGeneratorInterface alias for "api_platform.router"

and when I run the command: "./bin/console debug:container ApiPlatform\Core\Api\IriConverterInterface" I get this:

---------------- ------------------------------------------------------
Option Value
---------------- ------------------------------------------------------
Service ID api_platform.iri_converter
Class ApiPlatform\Core\Bridge\Symfony\Routing\IriConverter
Tags -
Public no
Synthetic no
Lazy no
Shared yes
Abstract no
Autowired no
Autoconfigured no
---------------- ------------------------------------------------------

I am running Symfony 4.4.22
If I manually add "(IriConverterInterface $iriConverter)" as argument inside the constructor and mouse over to it, Phpstorm tells me "Undefined class IriConverterInterface".

Even if I add the: "use ApiPlatform\Core\Api\IriConverterInterface" still no changes.
Just don't know what else to do.

Reply
Alessandro D. Avatar

Hi Victor,
i am embarrassed to mention that removing the "vendor" folder and re-install it fixed the issue.

Thanks for your assistance.
Alessandro

Reply

Hey Alessandro,

Awesome, I'm happy you were able to solve the problem yourself, good job! And thanks for sharing your solution with others.

Cheers!

Reply
akincer Avatar
akincer Avatar akincer | posted 1 year ago

Is there a good trick to figuring out what is causing the the "Cannot read property 'substr' of undefined" error? I have now successfully got LDAP backend auth working with a custom authenticator using the course code and the only other modifications being to the structure of the User entity. On login the ajax doesn't update to show the user is logged in and that message appears in the console. Refreshing shows the user is logged in.

Reply
akincer Avatar

Note it's because response.headers.location is in fact null testing proves. This is running on an apache server so is it possible there's a configuration issue here? Going down the rabbit hole of Google led me to something about CORS but I'm not sure that's where I need to look.

Reply

Hey Aaron!

Hmm. Is your frontend and backend on different domains? The Location header is set by our code when we log in - https://github.com/SymfonyC...

So, indeed, it does sound like a potential cors problem... but that should only be a problem if your backend and frontend are different domains. If that is the case, check out the config for https://github.com/nelmio/N...

That bundle is already installed - you may just need to tweak it’s config to trust your frontend and probably to send the Location header.

Let me know what you find!

Cheers!

Reply
Default user avatar
Default user avatar Aaron Kincer | weaverryan | posted 1 year ago | edited

Last one, I promise because I fixed it!

As it turns out, the execution order has the custom authenticator going first before the login function which makes sense when you look at what's going on in the login function. But you MUST do a RedirectResponse back to the app_login route and NOT the app_homepage (as was done in previous tutorials).

Interestingly, if you just do a return null like the symfony documentation suggests this causes an authentication failure error but if upon inspection the auth actually succeeds and refreshing proves it.

Thank you weaverryan for the assistance!

Reply

Hey @Aaron!

I'm really glad you sorted it out! Sorry for being absent during your debugging!

> As it turns out, the execution order has the custom authenticator going first before the login function which makes sense when you look at what's going on in the login function. But you MUST do a RedirectResponse back to the app_login route and NOT the app_homepage (as was done in previous tutorials).

Yup, you're totally right! So if you redirect both on success AND on error (I'm not saying you should, just doing a thought experiment) then your login controller will literally never be called :).

> Interestingly, if you just do a return null like the symfony documentation suggests this causes an authentication failure error but if upon inspection the auth actually succeeds and refreshing proves it.

One thing that may explain some of the mystery (at least for me) is that, in this tutorial, we don't actually use a custom authenticator. I took a shortcut and use json_login in security.yaml. This "authentication mechanism" does nothing on success (unless you configure it further), which means that the request continues to the login controller where we return our JSON (on error, json_login immediately returns the error, so the controller is never hit in that situation).

If you're using a custom authenticator, returning null from onAuthenticationSuccess should have had the same effect... I'm not sure why you were seeing an "authentication failure error" in that situation. That's a mystery to me...

Anyways, I'm glad you've got it sorted out :).

Cheres!

Reply
akincer Avatar

Apologies for so many replies.

I added the ability to do a GET on the /login route and when I run that via the browser address bar the logging shows up just fine. It simply doesn't when I actually do an auth POST action via the vue.js form. So the evidence suggest the actual login function in the SecurityController that the app_login route is tied to isn't actually running which definitely explains location being empty.

Reply
akincer Avatar

OK I think I've isolated WHAT is happening but I'm struggling on the HOW. I feel like I'm taking crazy pills honestly.

Below is my security configuration. Somehow, the vue.js login form ajax request is bypassing the Security controller app_login altogether which means Location is never set. I feel like I'm going crazy because the profiler says this -- Redirect from POST @app_login (6c5fb4)

I tried logging stuff inside the login function in the SecurityController and never found it in the profiler which is what tipped me off it wasn't actually being called. I even put a die statement in the beginning but that didn't cause auth to break.

providers:
my_ldap:
ldap:
service: Symfony\Component\Ldap\Ldap
base_dn: '%env(resolve:LDAP_BASE_DN)%'
search_dn: '%env(resolve:LDAP_SEARCH_DN)%'
search_password: '%env(resolve:LDAP_SEARCH_PASSWORD)%'
default_roles: ROLE_USER
user_provider:
entity:
class: App\Entity\User
property: userId
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
lazy: true
provider: my_ldap
json_login:
check_path: app_login
username_path: username
password_path: password
guard:
authenticators:
- App\Security\Authenticators\LdapLoginAuthenticator

In the profiler, I found the actual call to /login and see this as the first info entry -- Matched route "app_login".

Here's the code from that route:

/**
* @Route("/login", name="app_login", methods={"POST"})
*/
public function login(IriConverterInterface $iriConverter)
{
$this->logger->info("location: ".$iriConverter->getIriFromItem($this->getUser()));
...

That log entry doesn't exist. But log entries generated in my authenticator do.

I'm sure that this behavior is expected and there's some simple config changes I need to make. I have no idea where to start.

Reply
akincer Avatar

Same error running the site on the local symfony server so I'm sure I'm doing something wrong now.

I will say that I changed the user ID field from email to username since this is ldap and I'm grabbing the username from login to create a new user record. I did a search/replace for email to change to userId (AD shortname login) in the two vue.js components. In doing so, this also changed the v-model for the email form component and also the type from email to text. I'm guessing the problem is in there somewhere.

Oh one last thing probably worth mentioning is that this project was created via a new skeleton and all packages are running the latest versions so maybe that's an issue from the course code.

Reply
akincer Avatar

Nope, front end and back-end are all on the same box and the domain is contiguous throughout. The LDAP isn't of course, but I wouldn't think that would be an issue.

Going to create a local version and test with the symfony web server that I would guess is configured quite permissively.

Reply

In enterprise applications we usually return the application wide entitlements of the "role" to which the user belongs. The UI then displays only those menu links that the user is entitled to use.

Reply

sorry me but I didn't get your point. Could you elaborate a bit more.
Cheers!

Reply

What I meant is that once the login is successfull we return an array of "entitlements" based on the role assigned to the user..For example if the role is meant to create and edit data using specific pages lets say customer and product then on a successful login the api will return an array of that contains an array that will contain CUSTOMER | CREATE | EDIT and PRODUCT | CREATE | EDIT. The user interface will then ensure the appropriate actions are enabled and all other actions and pages are not available to the user.

Reply

In that case, a way that occurs to me is to fetch the user's roles before finishing the authentication process and set *those* roles on the user object, so the Security component can just work as it normally does.
Cheers!

1 Reply

I plan to do that. Thanks so much.

1 Reply
Romain L. Avatar
Romain L. Avatar Romain L. | posted 1 year ago

Hi,

first of all, thanks a lot for your invaluable screencasts.

I read the other comments and i'am facing the same issue with cookie problems for my frontend. To explain:

With this setup : Docker + Traefik + Api Platform with Twig

Everything is on the same nginx container at this url api.mysite.localhost
login endpoint : api.mysite.localhost/login (symfony controller)

I followed your tutorial and first i implemented json_login and then switch to custom guard.

So in my twig template, i embed Vuejs inside it (exactly like you) with the login form. It's working fine and when i check the logs, i have this:

security.INFO: Guard authentication successful!
security.DEBUG: Guard authenticator set success response.
security.DEBUG: Stored the security token in the session.
...
request.INFO: Matched route "api_users_get_item". <= making an axios request to retrieve the user data
...
Read existing security token from the session. {.... Guard\\Token\\PostAuthenticationGuardToken"}

So in this case i can see that Guard is successful, a session is created (i can see it in my session table), and the response from the /login page
includes a cookie.
I can see this cookie in the network tab (in the cookie response and response headers) and more important, the cookie is also stored in the storage tab of the developper toolbar.

Now the problem is when i do :
With this setup : Docker + Traefik + Api Platform + Quasar (with SSR mode or not)
this time, no twig at all, it's pure Vue with Vue router, Vuex,...

frontend : mysite.localhost
backend : api.mysitelocalhost (same as the firt setup above)

I have the same logic for the login form, but this time i am having trouble with the cookie. If i check the log, i have this:

security.INFO: Guard authentication successful!
security.DEBUG: Guard authenticator set success response.
security.DEBUG: Stored the security token in the session.
...
request.INFO: Matched route "api_users_get_item".
...
security.INFO: Populated the TokenStorage with an anonymous Token.

but if i check carrefuly the response header and the cookie response from the /login page, it's exactly the same thing as the first try.
The only difference is that the cookie is "not persisted" in the storage tab (i cannot see it in this tab in Firefox).
So the response from the guard is ok, the session is started, the guard is sending the cookie but it's not persisted then.
And it's logic that when i do the second request to the API that it's anonymous token (even if i have included withCredentials: true) because i don't have the cookie.

My framework.yaml configuration is:
cookie_secure: auto
cookie_samesite: none
cookie_domain: .mysite.localhost

with samesite on "none" or "lax", in both cases it's not working. i also tried lot of other options (use_cookies: true, without domain,...) but none is working.
I also tried with the "regular" json_login authentificator but same problem.
i also tried without traefik with something like this localhost:80 for frontend and localhost:8080 for api but not working also.

I have 2 questions:
- Just to be sure, do we agree that the PostAuthenticationGuardToken read the PHPSESSID cookie from the cookie storage?
- Since my cookies looks the same in the cookie response (network tab) from the /login page in the 2 cases, i don't understand why it's not persisted. Was it the same for others users?

To sum up:
my login endpoint : api.mysite.localhost/login
if i submit my login from api.mysite.localhost it works (and i can see my cookie from mysite.localhost) but if i submit my login from mysite.localhost (which is on a node js server) it's not working.

The only solution i see for now is to put my login form "outside" my frontend and put on api.mysite.localhost and then redirect to mysite.localhost but i'm not sure it's very clean and a good way to do it.

Any help would be really appreciated, thanks a lot :)

Reply
Romain L. Avatar

Wow, found the answer in the other threads of this screencast, i should have read everything before:)
With Axios, i was putting the withCredentials: true to my get request (to my user endpoint) but not on the login request but it's also needed, i don't understand why but it's super mandatory actually.

Reply

Hey Romain L.!

Thanks for posting that you found the solution... and your original post was PACKED with details - so good work :). I'm glad you found the solution because, as I was reading your original post, I was thinking "he's doing everything correct on the server and checking for the right things!".

Anyways, I'm glad you've got it working! About why it's needed on login, the withCredentials flag for Axios is ultimately passed to the underlying XMLHttpRequest functionality. From Mozilla: https://developer.mozilla.o...

> In addition, this flag is also used to indicate when cookies are to be ignored in the response.

So there ya go :). I hadn't ever researched to find the root cause until this moment, so now my curiosity is also satisfied.

Cheers!

Reply
Romain L. Avatar

Hi Ryan,
thank you for your nice word and for this investigation :)

Have a nice day

Reply
akincer Avatar
akincer Avatar akincer | posted 2 years ago

In today's episode of "where did I make a typo" we find that the "logged in as" being reported as literally "string" without quotes. It's both in the debug toolbar and in the form after login. Any ideas?

Reply

Hey @Aaron!

Ha! Yea... the more subtle the type, the harder it is to find it! Check the getUsername() method on your User class. The web debug toolbar, for example, literally "fetches the currently authenticated user" and then calls getUsername() on it to get the string there. Maybe you have a typo there.

Let me know!

Cheers!

Reply
akincer Avatar

Oh my. Apparently I didn't bother changing the default text when inserting a new user via the api. The username is literally "string". I was certain something weird was going on but it turns out everything is working exactly as it's supposed to. I've now graduated from typos to whatever you call that.

Reply

lol - Welcome to club!

Reply
akincer Avatar

That's what I thought but couldn't find anything wrong. It's just what comes out of the box with the course code.

/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUsername(): string
{
return (string) $this->username;
}

Reply

Hi I'm using ReactJs for my frontend application but I'm faceing the same issue mentioned in comments (I can see the Location Header in the Network tab but is undefined when I console.log(response.headers.location) )... but nothing of the comments help me... My NelmioCors bundle is installed and configured like this:

nelmio_cors:
defaults:
origin_regex: true
allow_origin: [ '%env(CORS_ALLOW_ORIGIN)%' ]
allow_methods: [ 'GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ]
allow_headers: [ 'Content-Type', 'Authorization' ]
allow_credentials: false
expose_headers: []
max_age: 0
hosts: []
forced_allow_origin_value: ~
paths:
'^/api/':
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_headers: ['X-Custom-Auth', 'Content-Type', 'Authorization']
allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTION']
expose_headers: ['Link', 'Location']
max_age: 3600
'^/':
origin_regex: true
allow_origin: [ '%env(CORS_ALLOW_ORIGIN)%' ]
allow_headers: ['X-Custom-Auth']
allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTION']
max_age: 3600
hosts: ['^api\.']


I can see the Location header in the Network Tab but is undefined when I console.log(response.headers.location) this is my fetch request:

export function login(values) {
return dispatch => {
dispatch(loading(true));

return fetch('/login', {
method: 'POST',
body: JSON.stringify(values),
})
.then(response => {
console.log(response.headers.location);}
)
};
}

any advice!? or tip... I already try to expose the 'Location' header but not result... Thanks in advance!!

Reply

well I fixed... this answer help me a lot, and here is another reference ... Like this examples shows I used insted the console.log(response.headers.get('location')) ... Thanks for this amazing tutorial I'm learning A LOT!!! I love it!!!

Reply

Hey Toshiro!

Sorry for replying so slowly! But I was happy to see that you figured it out - AND shared the answer 🎂

So basically, it *was* working, but fetch tries to (kind of) hide the Location header? I didn't know that! But that's good to know!

Cheers!

1 Reply

Hey weaverryan yeap more or less something like that, the code indeed was OK! perfectly in fact!!,... but in the two links that I shared I found information that suggested something like this and I did some tests and it worked perfectly. Don't worry about the delay, I understand perfectly, any way this force me to do some research and I learned a lot from it... and also I like the challenges :) !!! I am learning a lot with the symfony courses and I am applying the knowledge directly in a project that I have. Your team's videos have helped me A LOT!! Thanks for the effort and the good content. Whenever I discover something new I will try to share it,it is a pleasure to share knowledge with the community! Cheers!!! ─=≡Σ((( つ◕ل͜◕)つ

Reply
Nathan D. Avatar
Nathan D. Avatar Nathan D. | posted 2 years ago

Hi !

After many hours struggling to set up my docker environment to connect my API to my frontend I finally hit another issue.

1 - I log in to my API on the endpoint /login and receive the good Set-Cookie header
2 - I try to get some data from my API /API/products and I got a 401 unauthorized like the cookie sent by the log in request was not saved or send on the second call.

My API and my Frontend (VueJS) have each one a container and a different domain name. I'm using Treafik between my 2 containers to be able to communicate from my frontend to my API.

I tried axios with the config option withCredentials : true but nothing change ... And on my frontend my file are organized like in the Code Vue.JS tutorial. So I have a service for login and another one for each entity.

import axios from 'axios';

export async function logIn(email, password) {

return axios.post('http://api.domain.docker.localhost:4080/login',
{
email,
password
},
{ withCredentials: true }
);
}

export function fetchCustomers(searchTerm) {

const params = {};

if (searchTerm) {
params.lastname = searchTerm;
}

return axios.get('http://api.domain.docker.localhost:4080/api/customers',
params,
{ withCredentials: true }
);
}

Do you have an idea on what I'm doing wrong ?

Reply

Hi Nathan D.!

Hmm, my guess is that you've having CORS issues and/or the "SameSite" cookie that Symfony uses. Btw, I'm not at all familiar with Treafik - so I may be misunderstanding a big part of your infrastructure :).

The fact that you *do* seem to be able to make AJAX requests makes me think that CORS isn't the problem. You said:

> I try to get some data from my API /API/products and I got a 401 unauthorized like the cookie sent by the log in request was not saved or send on the second call.

That sounds like a SameSite cookie problem... or some sort of cookie problem in general. What are the 2 domains exactly? As I understand it, as long as both your frontend and backend are under the same "main" domain (e.g. docker.localhost in your case), then a SameSite cookie set by your API *should* be allowed to be stored by your frontend domain. However, I could be wrong - or there could be something else going on. It certainly seems like it's not being stored.

Try this: view the raw response (including headers) of the login API request. You should see a Set-Cookie header. What does it look like? Does it have a domain set? Or anything else that looks odd? To prove/disprove my SameSite theory, set this line - https://github.com/symfony/... - to "none" - that will disable SameSite cookies. Also, are both domains using http or https?

Cheers!

Reply
Nathan D. Avatar

FIXED !

Inside the /frontend-project/assets/js/services/security-service.js (FRONTEND APP)


axios.defaults.baseURL = 'http://api.domain.docker.localhost:4080';
axios.defaults.headers.common['Authorization'] = localStorage.getItem('token');
axios.defaults.withCredentials = true;


+ Remove the "{ withCredentials: true }" from axios.get() and axios.post() (axios calls)
-> you should be able to see Authorization and Cookie key inside your request headers.

I'm going to find a way to centralize that in my VUe.js app, because right now is only on the top of my security-service.js but other services are using it. (advice are welcome =D)

Inside the /api-project/config/packages/nelmio_cors.yaml (API)

nelmio_cors:
defaults:
origin_regex: true
allow_credentials: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization', 'Preload', 'Fields']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

-> you should see the Access-Control-Allow-Credentials: true and all the other good headers

Inside the .env.local (API) (Don't do that in production)

CORS_ALLOW_ORIGIN=^https?://.*?$

-> this allows everyone to call your API. So every container can reach your API. Again, don't push that to production.

In order to communicate between your containers use a reverse proxy: Traefik, don't forget to install it and run the container. I personally run it outside my project, I launch the container when I start my day and it used by every projects ;)
Inside the docker-compose.yml (API) you should label your Apache or Nginx like that:

labels:
- "traefik.http.routers.alfred-api.rule=Host(`api.domain.docker.localhost`)"
- "traefik.http.middlewares.testheader.headers.accesscontrolallowmethods=GET, POST, PUT, DELETE"
- "traefik.http.middlewares.testheader.headers.accesscontrolalloworigin=*"
- "traefik.http.middlewares.testheader.headers.accesscontrolallowcredentials=true"

Inside the docker-compose.yml (FRONTEND APP) you should label your Apache or Nginx like that:

labels:
- "traefik.http.routers.alfred-client.rule=Host(`client.domain.docker.localhost`)"

-> now you are able to call your API from your frontend app by
axios.get('http://api.domain.docker.localhost:<port>')
use a port if you are exposing a custom port like me (4080 for my API and 5080 for my frontend)

-> if you are building a Flutter App in order to support mobile you need nothing else. ;) Just use the dio library and call on http://api.domain.docker.localhost:<port>

Have a great day !

Reply

Ha, pff - I JUST saw this... after my other reply. Nice work!

Reply
Nathan D. Avatar

Hey Ryan !

So I check what you said about the same_site cookie and everything is ok.

I'm coming back because I actually not solved the issue ... I mean not all the issue. Got a strange behavior.

API = http://api.domain.docker.localhost:4080
FRONTEND = http://client.domain.docker.localhost:5080

I open chrome with a sweet incognito mode and push these requests from the FRONT to the API :
1- POST /login
Response Headers > Set-Cookie: PHPSESSID=55161a3ad7b7c97732bb20a4ed3c2701; path=/; HttpOnly; SameSite=lax

2- POST /api/customers (get some customers' data!)
401 Unauthorized
Request Headers > No Cookie header. Of course, I'm not authorized !

3- POST /login (exact same call than the first one)
Request Headers > Cookie: PHPSESSID=55161a3ad7b7c97732bb20a4ed3c2701
Response Headers > Set-Cookie: PHPSESSID=55161a3ad7b7c97732bb20a4ed3c2701; path=/; HttpOnly; SameSite=lax
So now the Cookie is here, don't why but it's here, hahaha … so much fun programming !

4- POST /api/customers (get some customers' data if can this time)
Request Headers > Cookie: PHPSESSID=55161a3ad7b7c97732bb20a4ed3c2701
So now everything works !

So after that I have build a brand new vuejs app with the vue-cli and gave it a shot on the same call. Here are the results from a frontend running on localhost:8080 (yarn serve) same axios config

1- Response Headers > Set-Cookie: PHPSESSID=55161a3ad7b7c97732bb20a4ed3c2701; path=/; HttpOnly; SameSite=lax (with a small orange sign at the end, don't know what is it and nothing advertise it)

2- 401 Unauthorized
Request Headers > No Cookie header.

3- Response Headers > Set-Cookie: PHPSESSID=55161a3ad7b7c97732bb20a4ed3c2701; path=/; HttpOnly; SameSite=lax (with a small orange sign at the end, don't know what is it and nothing advertise it)

4- 401 Unauthorized
Request Headers > No Cookie header.

I feel like I'm missing something with my browser or the localhost stuff. Don't know what it is but I can feel it. I read some stuff about chrome not putting cookie for localhost so … I’m going to do a container for my VueJs App.

And I’m also thinking :

How can I autoLogin or check if the user is already logged in on my front when he comes back ? Because this is an httpOnly cookie and I can’t read it from my vuejs app … Should I create a link on my API to test the session and call it on the created() function of my highest component ? Then using a guard to let him pass or not.

Am I over thinking ? Yay ! Learning time ! Love it !

Reply

Hey Nathan D.!

Hmm, this IS weird.

First, I think I can explain why the vue-cli version didn't work - and that orange sign. Because localhost:8080 is not the same domain as the backend, your browser (correctly) "rejected" the cookie. That is why you *never* see the cookie set in that case.

But what I can't figure out is why you're getting the behavior in the first case. So... it basically seems like you need to POST /login *two* times... and then it works? That does not smell right. I cannot think of any reason why the first response to /login would contain the cookie... but it would not be immediately used on all future requests. I'm stumped on this :/

> How can I autoLogin or check if the user is already logged in on my front when he comes back ? Because this is an httpOnly cookie and I can’t read it from my vuejs app … Should I create a link on my API to test the session and call it on the created() function of my highest component ? Then using a guard to let him pass or not

Basically... yes. This is one of the problems with pure HTML frontends. In a more traditional situation, when you reload the page, the backend server would know that you're authenticated and you would be able to render user information on the page - or even render some user information in a JavaScript variable so that your front-end can immediately use it. With a pure frontend, there's no way (at page load) to know if you're authenticated. You need to ask the backend. There *is* a workaround - it would be to store some info about the user in local storage. Do not *rely* on this information fully - just use it as a local cache. So, on page load, set your initial user data (in Vue) to info from local storage (if it exists). Then, on created(), make a request to get the currently-authenticated user's info. When that finishes, update the data and the local storage. On logout, clear local storage.

99% of the time, what you have in local storage will match what you get back in the AJAX request. Just be careful to (A) not store anything sensitive (this is JavaScript after all) and (B) not trust this information - use it to render some basic things on the page initially... but don't let the user *actually* do anything sensitive until you know they are auth'ed. That's... actually pretty easy - because if they are not truly auth'ed, any AJAX calls will fail.

Cheers!

Reply
Nathan D. Avatar
Nathan D. Avatar Nathan D. | weaverryan | posted 2 years ago | edited

Hey weaverryan,

Getting back to you for more explanation about the cookie lax stuff.
So after many hours my apps works well on local. So the next step was to deploy it to give the world access to it ! I'm kidding just trying to figure out a way to deploy before building my crazy stuff. But at list some data can be share and user can log in / register

You can go and check :
API https://hidden-brushlands-0...
FRONTEND https://lit-bayou-88339.her...

Annnndd !!! I got an error with my cookie on the FRONTEND app :( (SameSite=lax ... cross-site response ....)
So the login works but the next call to /api/user/:id breaks cause of the cookie.

So what is happening ? I'm on the same herokuapp.com domain so ... it should work. Am I missing something about cookies ?

Reply
Nathan D. Avatar

I fix it by :

cookie_samesite: none
cookie_secure: true

I'm feeling like I'm creating a breach in my app ... If someone sends an email with a fake button the cookie will be sent thought with the request. So if behind this button there is a DELETE request or it's hitting a critical API endpoint ... That's too bad ... Am I right ?

Reply

Hey Nathan D.!

Sorry for the slow reply! My guess is that cookie_secure is *not* important for your solution - and you could leave this at the default and things would work. This defaults to "auto", which basically means "use an https cookie if the URL is https, else use http". But, let me know if you change this back to the default and it doesn't work.

The cookie_samesite: none IS probably what is making this work. But yea, you're disabling samesite cookies... which is really nice because samesite cookies give you automatic CSRF protection (except for old browsers). So, here's the issue (and, btw, thanks for deploying the site - it made it super easy to test - I created an account!) - *normally*, your 2 domains - hidden-brushlands-05800.herokuapp.com and lit-bayou-88339.herokuapp.com - would be considered living "under the same domain" for SameSite purposes - both live under herokuapp.com. But, there is a public registry - the "public suffix list" - of domains where this rule should *not* be followed - herokuapp.com is one of them - https://devcenter.heroku.co... - and that makes sense, this isn't a *true* "root" domain, and if they allowed SameSite cookies across this, you could write an app that uses the cookies from someone else's app under Heroku.

So... this will *probably* not be a real problem once you *truly* deploy your frontend and backend to production (assuming each *truly* lives under the same, final custom domain).

By the way, earlier you mentioned that the FIRST request to /login would fail and the 2nd would work. Now that you've deployed your app, I can see why :). You're seeing a CORS preflight request. Your app *first* makes an OPTIONS request to /login to see if it has permission to make an AJAX request to that domain. And then, once it hears back that it *does*, it THEN makes the true request to /login. So actually, for me, the first AJAX request would "work" (the CORS OPTIONS preflight) and the 2 would fail (assuming I had an invalid email/password).

Cheers!

Reply
Nathan D. Avatar
Nathan D. Avatar Nathan D. | weaverryan | posted 2 years ago | edited

Hello,weaverryan

That's some very good and precious explanations ! Everything is clear now ! :)

For the Same site Cookie I was actually thinking to put those settings inside environment variables but it turns out that Symfony is checking if it's allowed for each node and I got this error message :

A dynamic value is not compatible with a "Symfony\Component\Config\Definition\EnumNode" node type at path "framework.session.cookie_secure"

Anyway Heroku with same site = none going to be fine for testing purpose ... When it will be time to release publicly I'm going to make sure to not screw up the security part. (And see how I can connect a mobile app ... baaaa to must stuff to think about !)

For now, I'm going to focus on functionalities ! Because my app is basically doing nothing for now ... AHhahaha! I got some other stuff to tackle like AWS S3 bucket, wkhtmltopdf ! Easy on local but when it's time to deploy is always another story ... So excited to code all the stuff I got in my head and see the result ! Thanks again for your work at SymfonyCast and for taking time to answer my questions.

Have a great day !

Reply

Hey Nathan D.!

It sounds like you have a lot of exciting stuff to work on :). I only went through registration and login, but the site already looked (and felt) really cool - nice work ;).

Cheers!

Reply

For all those having CORS issues:

So I have the symfony application running on a docker container with domain `symfony.localhost`.
Then I have the angular dev application served to `angular.symfony.localhost`. Edit your hosts file and add `127.0.0.1 angular.symfony.localhost`

Then run the angular serve with the following command `ng serve --host="angular.symfony.localhost"`

This still doesnt do anything, you need to install the NelmioCorsBundle on the symfony application. Inside nelmio_cors.yaml under `expose_headers` add Location to the array, to be able to access the Location Header from the angular app.

Then in framework.yaml under session, add cookie_domain: 'symfony.localhost'.

Lastly make sure that when you call the `me` endpoint or any other endpoint that you need to send the cookie, set `withCredentials: true` inside the request, in my case on angular:


return this.http.get(`${environment.backendHost}/me`, {observe: 'response', withCredentials: true})
.pipe(
map((response: HttpResponse<any>) => {
this.isAuthenticated = true;
this.http.get(`${environment.backendHost}${response.headers.get('Location')}`).subscribe(
(res) => {
this.user = new UserModel();
this.user.email = res['email'];
this.user.id = res['@id'];
this.user.nickname = res['nickname'];
}
);
}),
catchError((e: HttpErrorResponse) => {
return throwError(e);
})
);
Reply

Hey gabb!

Thanks for sharing your experience! I was surprised you needed cookie_domain: 'symfony.localhost'... since your backend is running on symfony.localhost already... but I'm just taking a guess here. Mostly, what you did makes total sense :).

Cheers!

Reply
Default user avatar
Default user avatar Gabriel Caruana | weaverryan | posted 2 years ago

Quick question, so with the me endpoint, it means that the browser would have to send that request everytime the user refreshes the page. Would it make sense instead to store a cookie on the frontend with the user model and then delete it when user calls the logout endpoint? Is there some sort of security concerns this way?

Basically instead of calling the /me endpoint on refresh I just get the user model from cookie if it s there. Then I have an request interceptor that deletes the cookie anytime an endpoint returns 401.

Reply

Hey Gabriel Caruana!

> so with the me endpoint, it means that the browser would have to send that request everytime the user refreshes the page

Yep!

> Would it make sense instead to store a cookie on the frontend with the user model and then delete it when user calls the logout endpoint? Is there some sort of security concerns this way?

You do need to be a bit careful here... but as long as you don't rely on the cookie (or local storage) as *proof* that the user is authenticated AND you don't store anything sensitive there, you're probably ok. You could also use that locally "cached" data for initial page load and then STILL send a request to /me in the background to refresh the data. That would give you the data instantly but it would also stay up-to-date.

Another option here is to do something like this - https://symfonycasts.com/sc... - where you dump the user information on page load and read that in JavaScript. That doesn't / may not work for a single-page app, but is an easy method otherwise.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2
    }
}